diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index 0deb99eaab0..ce2e2d409a7 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -1275,6 +1275,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { } satisfies PluginManagedSkillResolution; }, async reset(skillKey, companyId) { + requireCapability(manifest, capabilitySet, "skills.managed"); const existing = await this.get(skillKey, companyId); const declaration = manifest.skills?.find((skill) => skill.skillKey === skillKey); if (!declaration) return existing; diff --git a/server/src/__tests__/plugin-managed-skills.test.ts b/server/src/__tests__/plugin-managed-skills.test.ts index 0cb6ddf89ad..deb82643ed2 100644 --- a/server/src/__tests__/plugin-managed-skills.test.ts +++ b/server/src/__tests__/plugin-managed-skills.test.ts @@ -174,6 +174,25 @@ describeEmbeddedPostgres("plugin-managed skills", () => { expect(reset.skill?.markdown).toContain("key: \"plugin/paperclip-managed-skills-test/wiki-maintainer\""); }); + it("does not rewrite managed skill bindings when defaults are unchanged", async () => { + const { companyId, services } = await seedCompanyAndPlugin(); + const created = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" }); + expect(created.skillId).toBeTruthy(); + + const [binding] = await db.select().from(pluginManagedResources); + const oldUpdatedAt = new Date("2026-01-01T00:00:00.000Z"); + await db + .update(pluginManagedResources) + .set({ updatedAt: oldUpdatedAt }) + .where(eq(pluginManagedResources.id, binding.id)); + + const reconciled = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" }); + const [bindingAfter] = await db.select().from(pluginManagedResources); + + expect(reconciled.status).toBe("resolved"); + expect(bindingAfter.updatedAt.toISOString()).toBe(oldUpdatedAt.toISOString()); + }); + it("relinks an existing canonical skill without overwriting operator edits", async () => { const { companyId, services } = await seedCompanyAndPlugin(); const created = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" }); diff --git a/server/src/__tests__/plugin-sdk-testing.test.ts b/server/src/__tests__/plugin-sdk-testing.test.ts new file mode 100644 index 00000000000..b3efa7e19cb --- /dev/null +++ b/server/src/__tests__/plugin-sdk-testing.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; +import { createTestHarness } from "@paperclipai/plugin-sdk/testing"; + +describe("plugin SDK test harness", () => { + it("requires skills.managed capability before resetting a missing declaration", async () => { + const manifest: PaperclipPluginManifestV1 = { + id: "paperclip.test-missing-managed-skill-capability", + apiVersion: 1, + version: "0.1.0", + displayName: "Missing Managed Skill Capability", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: [], + entrypoints: { worker: "./dist/worker.js" }, + skills: [{ + skillKey: "wiki-maintainer", + displayName: "Wiki Maintainer", + }], + }; + const harness = createTestHarness({ manifest }); + + await expect(harness.ctx.skills.managed.reset("unknown-skill", "company-1")).rejects.toThrow( + "missing required capability 'skills.managed'", + ); + }); +}); diff --git a/server/src/services/plugin-managed-skills.ts b/server/src/services/plugin-managed-skills.ts index 6610f6cb464..0d6ca9c3549 100644 --- a/server/src/services/plugin-managed-skills.ts +++ b/server/src/services/plugin-managed-skills.ts @@ -122,6 +122,17 @@ function buildSkillDefaults( }; } +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + function resolution( pluginKey: string, companyId: string, @@ -177,6 +188,12 @@ export function pluginManagedSkillService( const defaultsJson = buildSkillDefaults(options.pluginKey, declaration); const existing = await getBinding(companyId, declaration.skillKey); if (existing) { + if ( + existing.resourceId === skillId && + stableJson(existing.defaultsJson) === stableJson(defaultsJson) + ) { + return existing; + } return db .update(pluginManagedResources) .set({