From 389bbb6f991d08f369fb5e35bf15eec0f42afd1a Mon Sep 17 00:00:00 2001 From: "Pawla Abdul (Bot)" Date: Tue, 14 Apr 2026 11:13:18 +0000 Subject: [PATCH] Add ServerAdapterModule capabilities from fork's adapter-utils 0.3.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/server/index.ts | 21 +++++- src/server/models.test.ts | 51 +++++++++++++ src/server/models.ts | 21 ++++++ src/server/session.test.ts | 41 +++++++++++ src/server/session.ts | 37 +++++----- src/server/skills.test.ts | 142 +++++++++++++++++++++++++++++++++++++ src/server/skills.ts | 101 ++++++++++++++++++++++++++ 7 files changed, 395 insertions(+), 19 deletions(-) create mode 100644 src/server/models.test.ts create mode 100644 src/server/models.ts create mode 100644 src/server/skills.test.ts create mode 100644 src/server/skills.ts diff --git a/src/server/index.ts b/src/server/index.ts index e83bc86..81de51d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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 }; diff --git a/src/server/models.test.ts b/src/server/models.test.ts new file mode 100644 index 0000000..a94313e --- /dev/null +++ b/src/server/models.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { listK8sModels } from "./models.js"; + +describe("listK8sModels", () => { + const savedEnv: Record = {}; + + 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); + }); +}); diff --git a/src/server/models.ts b/src/server/models.ts new file mode 100644 index 0000000..67cc0ee --- /dev/null +++ b/src/server/models.ts @@ -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 { + return isBedrockEnv() ? BEDROCK_MODELS : DIRECT_MODELS; +} diff --git a/src/server/session.test.ts b/src/server/session.test.ts index ecc5a02..9c9af90 100644 --- a/src/server/session.test.ts +++ b/src/server/session.test.ts @@ -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(); diff --git a/src/server/session.ts b/src/server/session.ts index 3c88da3..88dc188 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -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) { + 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; - const sessionId = readNonEmptyString(record.sessionId) ?? readNonEmptyString(record.session_id); + const { sessionId, cwd, workspaceId, repoUrl, repoRef, promptBundleKey } = + extractSessionFields(raw as Record); 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 | 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 | null) { diff --git a/src/server/skills.test.ts b/src/server/skills.test.ts new file mode 100644 index 0000000..b9abc73 --- /dev/null +++ b/src/server/skills.test.ts @@ -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); + }); +}); diff --git a/src/server/skills.ts b/src/server/skills.ts new file mode 100644 index 0000000..c76d8a5 --- /dev/null +++ b/src/server/skills.ts @@ -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, +): Promise { + 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 { + return buildK8sSkillSnapshot(ctx.config); +} + +export async function syncK8sSkills( + ctx: AdapterSkillContext, + _desiredSkills: string[], +): Promise { + return buildK8sSkillSnapshot(ctx.config); +}