Add ServerAdapterModule capabilities from fork's adapter-utils 0.3.1

Bring the K8s adapter up to parity with the fork's ServerAdapterModule
contract by adding sessionManagement, listSkills/syncSkills, listModels
with Bedrock detection, and promptBundleKey support in the session codec.

- Declare sessionManagement with nativeContextManagement: "confirmed"
  so Paperclip skips threshold-based session compaction (Claude manages
  its own context)
- Add ephemeral skill management (listSkills/syncSkills) mirroring
  claude_local — reports skill state without runtime persistence since
  skills are injected via prompt bundle into ephemeral Job pods
- Add listModels() with Bedrock environment detection, returning
  region-qualified model IDs when CLAUDE_CODE_USE_BEDROCK or
  ANTHROPIC_BEDROCK_BASE_URL are set
- Extend session codec to round-trip promptBundleKey field
- Remove the `as ServerAdapterModule` cast — the return type now
  satisfies the full interface

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-14 11:13:18 +00:00
parent 10a5004c02
commit 389bbb6f99
7 changed files with 395 additions and 19 deletions
+19 -2
View File
@@ -1,9 +1,22 @@
import type { ServerAdapterModule } from "@paperclipai/adapter-utils";
import type { ServerAdapterModule, AdapterSessionManagement } from "@paperclipai/adapter-utils";
import { type, models, agentConfigurationDoc } from "../index.js";
import { execute } from "./execute.js";
import { testEnvironment } from "./test.js";
import { sessionCodec } from "./session.js";
import { getConfigSchema } from "./config-schema.js";
import { listK8sSkills, syncK8sSkills } from "./skills.js";
import { listK8sModels } from "./models.js";
const sessionManagement: AdapterSessionManagement = {
supportsSessionResume: true,
nativeContextManagement: "confirmed",
defaultSessionCompaction: {
enabled: true,
maxSessionRuns: 0,
maxRawInputTokens: 0,
maxSessionAgeHours: 0,
},
};
export function createServerAdapter(): ServerAdapterModule {
return {
@@ -11,11 +24,15 @@ export function createServerAdapter(): ServerAdapterModule {
execute,
testEnvironment,
sessionCodec,
sessionManagement,
models,
listModels: listK8sModels,
listSkills: listK8sSkills,
syncSkills: syncK8sSkills,
supportsLocalAgentJwt: true,
agentConfigurationDoc,
getConfigSchema,
} as ServerAdapterModule;
};
}
export { execute, testEnvironment, sessionCodec };
+51
View File
@@ -0,0 +1,51 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { listK8sModels } from "./models.js";
describe("listK8sModels", () => {
const savedEnv: Record<string, string | undefined> = {};
beforeEach(() => {
savedEnv.CLAUDE_CODE_USE_BEDROCK = process.env.CLAUDE_CODE_USE_BEDROCK;
savedEnv.ANTHROPIC_BEDROCK_BASE_URL = process.env.ANTHROPIC_BEDROCK_BASE_URL;
delete process.env.CLAUDE_CODE_USE_BEDROCK;
delete process.env.ANTHROPIC_BEDROCK_BASE_URL;
});
afterEach(() => {
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
it("returns direct API models by default", async () => {
const models = await listK8sModels();
expect(models.some((m) => m.id === "claude-opus-4-6")).toBe(true);
expect(models.every((m) => !m.id.includes("anthropic."))).toBe(true);
});
it("returns Bedrock models when CLAUDE_CODE_USE_BEDROCK=1", async () => {
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
const models = await listK8sModels();
expect(models.some((m) => m.id.includes("anthropic."))).toBe(true);
expect(models.every((m) => !m.id.startsWith("claude-"))).toBe(true);
});
it("returns Bedrock models when CLAUDE_CODE_USE_BEDROCK=true", async () => {
process.env.CLAUDE_CODE_USE_BEDROCK = "true";
const models = await listK8sModels();
expect(models.some((m) => m.id.includes("anthropic."))).toBe(true);
});
it("returns Bedrock models when ANTHROPIC_BEDROCK_BASE_URL is set", async () => {
process.env.ANTHROPIC_BEDROCK_BASE_URL = "https://bedrock.us-east-1.amazonaws.com";
const models = await listK8sModels();
expect(models.some((m) => m.id.includes("anthropic."))).toBe(true);
});
it("ignores blank ANTHROPIC_BEDROCK_BASE_URL", async () => {
process.env.ANTHROPIC_BEDROCK_BASE_URL = " ";
const models = await listK8sModels();
expect(models.some((m) => m.id === "claude-opus-4-6")).toBe(true);
});
});
+21
View File
@@ -0,0 +1,21 @@
import type { AdapterModel } from "@paperclipai/adapter-utils";
import { models as DIRECT_MODELS } from "../index.js";
const BEDROCK_MODELS: AdapterModel[] = [
{ id: "us.anthropic.claude-opus-4-6-v1", label: "Bedrock Opus 4.6" },
{ id: "us.anthropic.claude-sonnet-4-5-20250929-v2:0", label: "Bedrock Sonnet 4.5" },
{ id: "us.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Bedrock Haiku 4.5" },
];
function isBedrockEnv(): boolean {
return (
process.env.CLAUDE_CODE_USE_BEDROCK === "1" ||
process.env.CLAUDE_CODE_USE_BEDROCK === "true" ||
(typeof process.env.ANTHROPIC_BEDROCK_BASE_URL === "string" &&
process.env.ANTHROPIC_BEDROCK_BASE_URL.trim().length > 0)
);
}
export async function listK8sModels(): Promise<AdapterModel[]> {
return isBedrockEnv() ? BEDROCK_MODELS : DIRECT_MODELS;
}
+41
View File
@@ -92,6 +92,37 @@ describe("sessionCodec", () => {
repoRef: "main",
});
});
it("maps promptBundleKey", () => {
expect(deserialize({ sessionId: "s", promptBundleKey: "bundle-1" })?.promptBundleKey).toBe("bundle-1");
});
it("maps prompt_bundle_key as fallback", () => {
expect(deserialize({ sessionId: "s", prompt_bundle_key: "bundle-2" })?.promptBundleKey).toBe("bundle-2");
});
it("prefers promptBundleKey over prompt_bundle_key", () => {
const result = deserialize({ sessionId: "s", promptBundleKey: "a", prompt_bundle_key: "b" });
expect(result?.promptBundleKey).toBe("a");
});
it("omits promptBundleKey when empty", () => {
const result = deserialize({ sessionId: "s", promptBundleKey: "" });
expect(result).not.toHaveProperty("promptBundleKey");
});
it("includes promptBundleKey in full round-trip", () => {
const result = deserialize({
sessionId: "sess_abc",
cwd: "/work",
promptBundleKey: "bundle-key-123",
});
expect(result).toEqual({
sessionId: "sess_abc",
cwd: "/work",
promptBundleKey: "bundle-key-123",
});
});
});
describe("serialize", () => {
@@ -125,6 +156,16 @@ describe("sessionCodec", () => {
expect(serialize(input)).toEqual(input);
});
it("serializes promptBundleKey", () => {
const result = serialize({ sessionId: "s", promptBundleKey: "bundle-1" });
expect(result?.promptBundleKey).toBe("bundle-1");
});
it("omits promptBundleKey when empty", () => {
const result = serialize({ sessionId: "s", promptBundleKey: "" });
expect(result).not.toHaveProperty("promptBundleKey");
});
it("omits undefined fields", () => {
const result = serialize({ sessionId: "sess_abc" });
expect(result).not.toBeNull();
+20 -17
View File
@@ -4,44 +4,47 @@ function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function extractSessionFields(record: Record<string, unknown>) {
const sessionId = readNonEmptyString(record.sessionId) ?? readNonEmptyString(record.session_id);
const cwd =
readNonEmptyString(record.cwd) ??
readNonEmptyString(record.workdir) ??
readNonEmptyString(record.folder);
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
const promptBundleKey =
readNonEmptyString(record.promptBundleKey) ?? readNonEmptyString(record.prompt_bundle_key);
return { sessionId, cwd, workspaceId, repoUrl, repoRef, promptBundleKey };
}
export const sessionCodec: AdapterSessionCodec = {
deserialize(raw: unknown) {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
const record = raw as Record<string, unknown>;
const sessionId = readNonEmptyString(record.sessionId) ?? readNonEmptyString(record.session_id);
const { sessionId, cwd, workspaceId, repoUrl, repoRef, promptBundleKey } =
extractSessionFields(raw as Record<string, unknown>);
if (!sessionId) return null;
const cwd =
readNonEmptyString(record.cwd) ??
readNonEmptyString(record.workdir) ??
readNonEmptyString(record.folder);
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
...(promptBundleKey ? { promptBundleKey } : {}),
};
},
serialize(params: Record<string, unknown> | null) {
if (!params) return null;
const sessionId = readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
const { sessionId, cwd, workspaceId, repoUrl, repoRef, promptBundleKey } =
extractSessionFields(params);
if (!sessionId) return null;
const cwd =
readNonEmptyString(params.cwd) ??
readNonEmptyString(params.workdir) ??
readNonEmptyString(params.folder);
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
...(promptBundleKey ? { promptBundleKey } : {}),
};
},
getDisplayId(params: Record<string, unknown> | null) {
+142
View File
@@ -0,0 +1,142 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { AdapterSkillContext } from "@paperclipai/adapter-utils";
vi.mock("@paperclipai/adapter-utils/server-utils", () => ({
readPaperclipRuntimeSkillEntries: vi.fn().mockResolvedValue([]),
resolvePaperclipDesiredSkillNames: vi.fn().mockReturnValue([]),
readInstalledSkillTargets: vi.fn().mockResolvedValue(new Map()),
}));
import { listK8sSkills, syncK8sSkills } from "./skills.js";
import {
readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames,
readInstalledSkillTargets,
} from "@paperclipai/adapter-utils/server-utils";
const mockedReadEntries = vi.mocked(readPaperclipRuntimeSkillEntries);
const mockedResolveDesired = vi.mocked(resolvePaperclipDesiredSkillNames);
const mockedReadInstalled = vi.mocked(readInstalledSkillTargets);
const ctx: AdapterSkillContext = {
agentId: "agent-1",
companyId: "company-1",
adapterType: "claude_k8s",
config: {},
};
describe("listK8sSkills", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedReadEntries.mockResolvedValue([]);
mockedResolveDesired.mockReturnValue([]);
mockedReadInstalled.mockResolvedValue(new Map());
});
it("returns empty snapshot when no skills available", async () => {
const snapshot = await listK8sSkills(ctx);
expect(snapshot.adapterType).toBe("claude_k8s");
expect(snapshot.supported).toBe(true);
expect(snapshot.mode).toBe("ephemeral");
expect(snapshot.entries).toEqual([]);
expect(snapshot.warnings).toEqual([]);
});
it("marks desired available skills as configured", async () => {
mockedReadEntries.mockResolvedValue([
{ key: "paperclip/skill-a", runtimeName: "skill-a", source: "/skills/skill-a", required: false },
]);
mockedResolveDesired.mockReturnValue(["paperclip/skill-a"]);
const snapshot = await listK8sSkills(ctx);
expect(snapshot.entries).toHaveLength(1);
expect(snapshot.entries[0].state).toBe("configured");
expect(snapshot.entries[0].desired).toBe(true);
expect(snapshot.entries[0].origin).toBe("company_managed");
});
it("marks required skills with paperclip_required origin", async () => {
mockedReadEntries.mockResolvedValue([
{ key: "paperclip/core", runtimeName: "core", source: "/skills/core", required: true, requiredReason: "Bundled" },
]);
mockedResolveDesired.mockReturnValue(["paperclip/core"]);
const snapshot = await listK8sSkills(ctx);
expect(snapshot.entries[0].origin).toBe("paperclip_required");
expect(snapshot.entries[0].required).toBe(true);
});
it("adds warning for desired skill not in available entries", async () => {
mockedReadEntries.mockResolvedValue([]);
mockedResolveDesired.mockReturnValue(["missing/skill"]);
const snapshot = await listK8sSkills(ctx);
expect(snapshot.warnings).toHaveLength(1);
expect(snapshot.warnings[0]).toContain("missing/skill");
expect(snapshot.entries).toHaveLength(1);
expect(snapshot.entries[0].state).toBe("missing");
});
it("lists external user-installed skills", async () => {
mockedReadInstalled.mockResolvedValue(
new Map([["user-skill", { targetPath: "/paperclip/.claude/skills/user-skill" }]]),
);
const snapshot = await listK8sSkills(ctx);
expect(snapshot.entries).toHaveLength(1);
expect(snapshot.entries[0].state).toBe("external");
expect(snapshot.entries[0].origin).toBe("user_installed");
expect(snapshot.entries[0].managed).toBe(false);
});
it("does not duplicate externally installed skills that match available entries", async () => {
mockedReadEntries.mockResolvedValue([
{ key: "paperclip/skill-a", runtimeName: "skill-a", source: "/skills/skill-a", required: false },
]);
mockedReadInstalled.mockResolvedValue(
new Map([["skill-a", { targetPath: "/paperclip/.claude/skills/skill-a" }]]),
);
const snapshot = await listK8sSkills(ctx);
expect(snapshot.entries).toHaveLength(1);
expect(snapshot.entries[0].key).toBe("paperclip/skill-a");
});
it("sorts entries alphabetically by key", async () => {
mockedReadEntries.mockResolvedValue([
{ key: "paperclip/zebra", runtimeName: "zebra", source: "/skills/zebra", required: false },
{ key: "paperclip/alpha", runtimeName: "alpha", source: "/skills/alpha", required: false },
]);
const snapshot = await listK8sSkills(ctx);
expect(snapshot.entries[0].key).toBe("paperclip/alpha");
expect(snapshot.entries[1].key).toBe("paperclip/zebra");
});
});
describe("syncK8sSkills", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedReadEntries.mockResolvedValue([]);
mockedResolveDesired.mockReturnValue([]);
mockedReadInstalled.mockResolvedValue(new Map());
});
it("returns same snapshot as listK8sSkills (ephemeral pass-through)", async () => {
mockedReadEntries.mockResolvedValue([
{ key: "paperclip/skill-a", runtimeName: "skill-a", source: "/skills/skill-a", required: false },
]);
mockedResolveDesired.mockReturnValue(["paperclip/skill-a"]);
const listResult = await listK8sSkills(ctx);
vi.clearAllMocks();
mockedReadEntries.mockResolvedValue([
{ key: "paperclip/skill-a", runtimeName: "skill-a", source: "/skills/skill-a", required: false },
]);
mockedResolveDesired.mockReturnValue(["paperclip/skill-a"]);
mockedReadInstalled.mockResolvedValue(new Map());
const syncResult = await syncK8sSkills(ctx, ["paperclip/skill-a"]);
expect(syncResult).toEqual(listResult);
});
});
+101
View File
@@ -0,0 +1,101 @@
import type {
AdapterSkillContext,
AdapterSkillSnapshot,
AdapterSkillEntry,
} from "@paperclipai/adapter-utils";
import {
readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames,
readInstalledSkillTargets,
} from "@paperclipai/adapter-utils/server-utils";
import path from "node:path";
const SKILLS_HOME = "/paperclip/.claude/skills";
async function buildK8sSkillSnapshot(
config: Record<string, unknown>,
): Promise<AdapterSkillSnapshot> {
const availableEntries = await readPaperclipRuntimeSkillEntries(config, import.meta.dirname ?? __dirname);
const availableByKey = new Map(availableEntries.map((e) => [e.key, e]));
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
const desiredSet = new Set(desiredSkills);
const installed = await readInstalledSkillTargets(SKILLS_HOME);
const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({
key: entry.key,
runtimeName: entry.runtimeName,
desired: desiredSet.has(entry.key),
managed: true,
state: desiredSet.has(entry.key) ? "configured" : "available",
origin: entry.required ? "paperclip_required" : "company_managed",
originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
readOnly: false,
sourcePath: entry.source,
targetPath: null,
detail: desiredSet.has(entry.key)
? "Injected via prompt bundle into ephemeral K8s Job pods."
: null,
required: Boolean(entry.required),
requiredReason: entry.requiredReason ?? null,
}));
const warnings: string[] = [];
for (const desiredSkill of desiredSkills) {
if (availableByKey.has(desiredSkill)) continue;
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
entries.push({
key: desiredSkill,
runtimeName: null,
desired: true,
managed: true,
state: "missing",
origin: "external_unknown",
originLabel: "External or unavailable",
readOnly: false,
sourcePath: undefined,
targetPath: undefined,
detail: "Paperclip cannot find this skill in the runtime skills directory.",
});
}
for (const [name, installedEntry] of installed.entries()) {
if (availableEntries.some((e) => e.runtimeName === name)) continue;
entries.push({
key: name,
runtimeName: name,
desired: false,
managed: false,
state: "external",
origin: "user_installed",
originLabel: "User-installed",
locationLabel: "~/.claude/skills",
readOnly: true,
sourcePath: null,
targetPath: installedEntry.targetPath ?? path.join(SKILLS_HOME, name),
detail: "Installed outside Paperclip management in the Claude skills home.",
});
}
entries.sort((a, b) => a.key.localeCompare(b.key));
return {
adapterType: "claude_k8s",
supported: true,
mode: "ephemeral",
desiredSkills,
entries,
warnings,
};
}
export async function listK8sSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
return buildK8sSkillSnapshot(ctx.config);
}
export async function syncK8sSkills(
ctx: AdapterSkillContext,
_desiredSkills: string[],
): Promise<AdapterSkillSnapshot> {
return buildK8sSkillSnapshot(ctx.config);
}