Merge pull request #2441 from DanielSousa/skill-removal-ui

feat(company-skills): implement skill deletion (UI) with agent usage check
This commit is contained in:
Dotta 2026-04-07 21:51:51 -05:00 committed by GitHub
commit 54f93c1f27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 189 additions and 31 deletions

View file

@ -3,6 +3,7 @@ import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { companySkillRoutes } from "../routes/company-skills.js";
import { errorHandler } from "../middleware/index.js";
import { unprocessable } from "../errors.js";
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
@ -15,6 +16,7 @@ const mockAccessService = vi.hoisted(() => ({
const mockCompanySkillService = vi.hoisted(() => ({
importFromSource: vi.fn(),
deleteSkill: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
@ -62,6 +64,11 @@ describe("company skill mutation permissions", () => {
imported: [],
warnings: [],
});
mockCompanySkillService.deleteSkill.mockResolvedValue({
id: "skill-1",
slug: "find-skills",
name: "Find Skills",
});
mockLogActivity.mockResolvedValue(undefined);
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.hasPermission.mockResolvedValue(false);
@ -261,4 +268,28 @@ describe("company skill mutation permissions", () => {
"https://github.com/vercel-labs/agent-browser",
);
});
it("returns a blocking error when attempting to delete a skill still used by agents", async () => {
mockCompanySkillService.deleteSkill.mockRejectedValue(
unprocessable(
'Cannot delete skill "Find Skills" while it is still used by Builder, Reviewer. Detach it from those agents first.',
),
);
const res = await request(createApp({
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
}))
.delete("/api/companies/company-1/skills/skill-1");
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(res.body).toEqual({
error: 'Cannot delete skill "Find Skills" while it is still used by Builder, Reviewer. Detach it from those agents first.',
});
expect(mockCompanySkillService.deleteSkill).toHaveBeenCalledWith("company-1", "skill-1");
expect(mockLogActivity).not.toHaveBeenCalled();
});
});

View file

@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
import { and, asc, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { companySkills } from "@paperclipai/db";
import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
import type { PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils";
import type {
CompanySkill,
@ -2314,26 +2314,23 @@ export function companySkillService(db: Db) {
if (!row) return null;
const skill = toCompanySkill(row);
const usedByAgents = await usage(companyId, skill.key);
// Remove from any agent desiredSkills that reference this skill
const agentRows = await agents.list(companyId);
const allSkills = await listFull(companyId);
for (const agent of agentRows) {
const config = agent.adapterConfig as Record<string, unknown>;
const preference = readPaperclipSkillSyncPreference(config);
const referencesSkill = preference.desiredSkills.some((ref) => {
const resolved = resolveSkillReference(allSkills, ref);
return resolved.skill?.id === skillId;
});
if (referencesSkill) {
const filtered = preference.desiredSkills.filter((ref) => {
const resolved = resolveSkillReference(allSkills, ref);
return resolved.skill?.id !== skillId;
});
await agents.update(agent.id, {
adapterConfig: writePaperclipSkillSyncPreference(config, filtered),
});
}
if (usedByAgents.length > 0) {
const agentNames = usedByAgents.map((agent) => agent.name).sort((left, right) => left.localeCompare(right));
throw unprocessable(
`Cannot delete skill "${skill.name}" while it is still used by ${agentNames.join(", ")}. Detach it from those agents first.`,
{
skillId: skill.id,
skillKey: skill.key,
usedByAgents: usedByAgents.map((agent) => ({
id: agent.id,
name: agent.name,
urlKey: agent.urlKey,
adapterType: agent.adapterType,
})),
},
);
}
// Delete DB row