Files
PaperClipAI/server/src/__tests__/teams-catalog-service.test.ts
T
DottaandPaperclip 862a5758ba fix(agents): reduce hiring templates to role descriptions (#14985)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for work.
> - New agents receive role instructions from onboarding, the hiring skill, or a team package.
> - These sources repeat harness procedures and impose generic work policies.
> - They can crowd out the task and the harness instructions.
> - This pull request reduces those sources to short role descriptions.
> - It preserves configuration, skills, authentication, reporting lines, and approval controls.
> - The benefit is less repeated instruction text with explicit coverage for the default hiring path.

## Linked Issues or Issue Description

Refs #3307. The CEO template can impose a fixed delegation route instead of letting the agent choose how to fulfill the request. This change removes that route. It does not implement autonomous goal selection.

Related work: #14920 preserves stock Codex base instructions. #14948 reduces the generic manual and shared runtime prompts. #14961 improves native completion-tool descriptions. This PR is separate from those changes.

## What Changed

- Select only the short CEO `AGENTS.md` for new default CEO bundles. Keep the three former companion files as compatibility assets.
- Reduce the first-agent chief-of-staff prompt and coder, QA, UX, and security role examples.
- Reduce seven bundled team role bodies. Preserve their role, reporting, and skill metadata. Regenerate the catalog.
- Make hiring examples optional. Replace the long generic role manual with short role drafting guidance. Preserve explicit requester instructions.
- Add configuration and import coverage for native and legacy managed bundles, custom instructions, first-agent rendering, and catalog contents.
- Add an explicit-only hiring eval that starts from the production CEO default and checks one coder hire, independently computed JSON output, saved instructions, and worker reuse.
- Include the full prompt comparison and a separate three-request drafting simulation. Neither is a live provider comparison.

Prompt differences: [before and after](doc/plans/2026-10-02-hiring-template-prompt-diff.md). The CEO default falls from 1,897 to 20 words. The coder example falls from 652 to 18 words. Word counts describe instruction size, not outcome quality or billing.

## Verification

- PASS: 99 focused server tests and eight shipped-catalog tests.
- PASS: catalog generation and validation for four shipped teams.
- PASS: hiring skill validation.
- PASS: `pnpm -r typecheck`.
- PASS: `pnpm build`.
- INCOMPLETE: the full local `pnpm test:run` was stopped before rebase. Its original log is retained. This is not a completed full-suite pass. The full current-head GitHub CI workflow passed: https://github.com/paperclipai/paperclip/actions/runs/37073372419.
- PASS: `pnpm test:e2e:runner:typecheck` and `pnpm test:e2e:runner:unit` (63 files / 843 tests).
- PASS: discovery for the two new hiring cells, 50 existing everyday cells, and the full 438-cell catalog.
- PASS after rebase: 99 server tests, 11 catalog tests, 62 selected E2E support tests, and the E2E typecheck.
- PASS: all current-head PR checks at `57dcee147ed0b2d2e3cc657cd9e50fb16bf9ec25`: 51 successful check runs, two intentional Storybook skips, and successful Snyk status. Fresh Greptile is 5/5 with zero unresolved threads.
- PENDING follow-up: matched live hiring runs on frozen integration refs. No live outcome-quality or non-regression result is claimed from the configuration checks or this merge.

The new suite has two local native cells: Codex and ACPX Claude. It expects five provider turns per cell. It compares source-derived bundles, so the historical long templates remain admissible. Missing successful source-read receipts make a pair uncomparable. They do not establish a behavior regression or equivalence.

## Risks

- New default roles have fewer prescribed procedures. Live checks must determine whether a removed instruction was needed for an outcome.
- Existing custom and saved bundles keep their contents. The retained companion assets avoid a source-file compatibility break.
- Specialized Summarizer, Reflection Coach, and Wiki Maintainer prompts remain unchanged. Their product contracts need separate review.
- The generic non-CEO fallback reduction is in #14948. This PR alone does not provide its eight-word fallback.
- Configuration tests and drafting simulations do not establish live outcome quality. QA, UX, security, and chief-of-staff hiring behavior remain outside the new two-cell comparison.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, code editing, shell tools, and delegated verification. The runtime does not expose the exact deployment model ID or context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context to this change
- [x] I have specified the model used (with version and capability details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before requesting merge

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-02 17:56:42 -05:00

525 lines
22 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { readFile } from "node:fs/promises";
import { parseFrontmatterMarkdown } from "@paperclipai/shared/frontmatter";
import type { CatalogTeam } from "@paperclipai/shared";
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
list: vi.fn(),
}));
const mockCompanyPortabilityService = vi.hoisted(() => ({
previewImport: vi.fn(),
importBundle: vi.fn(),
}));
const mockCompanySkillService = vi.hoisted(() => ({
installFromCatalog: vi.fn(),
importFromSource: vi.fn(),
}));
vi.mock("../services/agents.js", () => ({
agentService: () => mockAgentService,
}));
vi.mock("../services/company-portability.js", () => ({
companyPortabilityService: () => mockCompanyPortabilityService,
}));
vi.mock("../services/company-skills.js", () => ({
companySkillService: () => mockCompanySkillService,
}));
vi.mock("../services/activity-log.js", () => ({
logActivity: vi.fn(),
}));
const {
collectCatalogTeamSkillPreparations,
listCatalogTeams,
readCatalogTeamProvenance,
teamsCatalogService,
} = await import("../services/teams-catalog.js");
const CORE_EXEC_TEAM_ID = "paperclipai:bundled:company-defaults:core-exec-team";
const CORE_EXEC_TEAM_HASH = (await listCatalogTeams()).find((team) => team.id === CORE_EXEC_TEAM_ID)!.contentHash;
function agentWithCatalogTeam(originHash: string | null, extra: Record<string, unknown> = {}) {
return {
id: `agent-${Math.random().toString(36).slice(2)}`,
companyId: "company-1",
metadata: {
paperclip: {
catalogTeam: {
catalogId: CORE_EXEC_TEAM_ID,
catalogKey: "paperclipai/bundled/company-defaults/core-exec-team",
...(originHash ? { originHash } : {}),
},
},
},
...extra,
};
}
describe("teamsCatalogService", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAgentService.getById.mockResolvedValue({
id: "manager-1",
companyId: "company-1",
name: "Engineering Manager",
});
mockCompanyPortabilityService.previewImport.mockResolvedValue({
include: { company: false, agents: true, projects: true, issues: true, skills: true },
targetCompanyId: "company-1",
targetCompanyName: "Paperclip",
collisionStrategy: "rename",
selectedAgentSlugs: ["ceo", "cto"],
plan: { companyAction: "none", agentPlans: [], projectPlans: [], issuePlans: [] },
manifest: { agents: [], skills: [], projects: [], issues: [], envInputs: [], includes: { company: false, agents: true, projects: true, issues: true, skills: true }, company: null, schemaVersion: 1, generatedAt: new Date().toISOString(), source: null, sidebar: null },
files: {},
envInputs: [],
warnings: [],
errors: [],
});
mockCompanyPortabilityService.importBundle.mockResolvedValue({
company: { id: "company-1", name: "Paperclip", action: "unchanged" },
agents: [],
projects: [],
envInputs: [],
warnings: [],
});
mockCompanySkillService.installFromCatalog.mockResolvedValue({
action: "created",
skill: { key: "paperclipai/bundled/paperclip-operations/task-planning" },
catalogSkill: { id: "paperclipai:bundled:paperclip-operations:task-planning" },
warnings: [],
});
mockCompanySkillService.importFromSource.mockResolvedValue({
imported: [],
warnings: [],
});
});
it("builds an inline portability source with catalog skill keys and target-manager reparenting", async () => {
const svc = teamsCatalogService({} as any);
const prepared = await svc.prepareCatalogTeamSource("company-1", "core-exec-team", {
targetManagerAgentId: "manager-1",
});
expect(prepared.errors).toEqual([]);
expect(prepared.source.files["COMPANY.md"]).toEqual(expect.stringContaining("Core Exec Team"));
expect(prepared.source.files["agents/ceo/AGENTS.md"]).toEqual(expect.stringContaining("paperclipai/bundled/paperclip-operations/task-planning"));
expect(prepared.source.files["agents/cto/AGENTS.md"]).toEqual(expect.stringContaining("paperclipai/bundled/software-development/github-pr-workflow"));
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("reportsToExistingAgentId: \"manager-1\""));
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("reportsToExistingAgentSlug: \"engineering-manager\""));
});
it.each(["core-exec-team", "product-engineering", "product-design", "content-machine"])(
"preserves %s role files and canonical skill references through catalog import",
async (teamRef) => {
const svc = teamsCatalogService({} as any);
const prepared = await svc.prepareCatalogTeamSource("company-1", teamRef);
expect(prepared.errors).toEqual([]);
for (const slug of prepared.team.agentSlugs) {
const filePath = `agents/${slug}/AGENTS.md`;
const original = parseFrontmatterMarkdown(await readFile(
new URL(`../../../packages/teams-catalog/${prepared.team.path}/${filePath}`, import.meta.url),
"utf8",
));
const imported = parseFrontmatterMarkdown(prepared.source.files[filePath] as string);
const requiredSkills = (original.frontmatter.skills as string[]).map((ref) => {
const requirement = prepared.team.requiredSkills.find((skill) => skill.ref === ref && skill.agentSlugs.includes(slug));
expect(requirement, `${filePath}: ${ref}`).toMatchObject({ resolved: true });
return requirement?.type === "catalog" ? requirement.catalogSkillKey : ref;
});
expect(imported.frontmatter, filePath).toEqual({ ...original.frontmatter, skills: requiredSkills });
expect(imported.body, filePath).toBe(original.body);
}
await svc.installCatalogTeam("company-1", teamRef);
const [importInput] = mockCompanyPortabilityService.importBundle.mock.calls.at(-1)!;
expect(importInput.source.files).toEqual(prepared.source.files);
const requiredCatalogIds = prepared.skillPreparations
.filter((skill) => skill.action === "catalog_install_required")
.map((skill) => skill.catalogSkillId).sort();
expect(mockCompanySkillService.installFromCatalog.mock.calls.map((call) => call[1].catalogSkillId).sort())
.toEqual(requiredCatalogIds);
},
);
it("resolves target-manager slug against same-company agents before rendering reparent metadata", async () => {
mockAgentService.list.mockResolvedValue([
{ id: "manager-1", companyId: "company-1", name: "CEO" },
]);
const svc = teamsCatalogService({} as any);
const prepared = await svc.prepareCatalogTeamSource("company-1", "core-exec-team", {
targetManagerSlug: "ceo",
});
expect(mockAgentService.list).toHaveBeenCalledWith("company-1");
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("reportsToExistingAgentId: \"manager-1\""));
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("reportsToExistingAgentSlug: \"ceo\""));
});
it("preserves package-declared Paperclip sidecar permissions while adding generated catalog provenance", async () => {
const svc = teamsCatalogService({} as any);
const prepared = await svc.prepareCatalogTeamSource("company-1", "product-engineering");
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("permissions:"));
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("canCreateAgents: true"));
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("catalogTeam:"));
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("catalogSlug: \"product-engineering\""));
});
it("preserves package sidecar permissions when generated target-manager metadata is merged onto the same root agent", async () => {
const svc = teamsCatalogService({} as any);
const prepared = await svc.prepareCatalogTeamSource("company-1", "product-engineering", {
targetManagerAgentId: "manager-1",
});
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("permissions:"));
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("canCreateAgents: true"));
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("reportsToExistingAgentId: \"manager-1\""));
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("reportsToExistingAgentSlug: \"engineering-manager\""));
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("catalogSlug: \"product-engineering\""));
});
it("rejects missing target-manager slugs instead of emitting unresolved reparent metadata", async () => {
mockAgentService.list.mockResolvedValue([]);
const svc = teamsCatalogService({} as any);
await expect(
svc.prepareCatalogTeamSource("company-1", "core-exec-team", {
targetManagerSlug: "missing-manager",
}),
).rejects.toMatchObject({ status: 404 });
});
it("previews through company portability in agent-safe mode", async () => {
const svc = teamsCatalogService({} as any);
const preview = await svc.previewCatalogTeamImport("company-1", "content-machine");
expect(preview.errors).toEqual([]);
expect(mockCompanyPortabilityService.previewImport).toHaveBeenCalledWith(
expect.objectContaining({
target: { mode: "existing_company", companyId: "company-1" },
include: expect.objectContaining({
company: false,
agents: true,
projects: true,
issues: true,
skills: true,
}),
source: expect.objectContaining({ type: "inline" }),
}),
{ mode: "agent_safe", sourceCompanyId: "company-1" },
);
});
it("forces catalog previews to exclude company metadata even when requested", async () => {
const svc = teamsCatalogService({} as any);
await svc.previewCatalogTeamImport("company-1", "content-machine", {
include: { company: true, agents: false },
});
expect(mockCompanyPortabilityService.previewImport).toHaveBeenCalledWith(
expect.objectContaining({
include: expect.objectContaining({
company: false,
agents: false,
}),
}),
{ mode: "agent_safe", sourceCompanyId: "company-1" },
);
});
it("preflights imports before installing catalog skills", async () => {
mockCompanyPortabilityService.previewImport.mockResolvedValueOnce({
include: { company: false, agents: true, projects: true, issues: true, skills: true },
targetCompanyId: "company-1",
targetCompanyName: "Paperclip",
collisionStrategy: "rename",
selectedAgentSlugs: ["ceo"],
plan: { companyAction: "none", agentPlans: [], projectPlans: [], issuePlans: [] },
manifest: { agents: [], skills: [], projects: [], issues: [], envInputs: [], includes: { company: false, agents: true, projects: true, issues: true, skills: true }, company: null, schemaVersion: 1, generatedAt: new Date().toISOString(), source: null, sidebar: null },
files: {},
envInputs: [],
warnings: [],
errors: ["Safe import does not allow process adapter type."],
});
const svc = teamsCatalogService({} as any);
await expect(svc.installCatalogTeam("company-1", "core-exec-team")).rejects.toMatchObject({ status: 422 });
expect(mockCompanySkillService.installFromCatalog).not.toHaveBeenCalled();
expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled();
});
it("does not install catalog skills when bundle import fails", async () => {
mockCompanyPortabilityService.importBundle.mockRejectedValueOnce(new Error("import failed"));
const svc = teamsCatalogService({} as any);
await expect(svc.installCatalogTeam("company-1", "core-exec-team")).rejects.toThrow("import failed");
expect(mockCompanySkillService.installFromCatalog).not.toHaveBeenCalled();
expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled();
});
it("surfaces post-import catalog skill install failures as warnings", async () => {
mockCompanySkillService.installFromCatalog.mockRejectedValueOnce(new Error("catalog unavailable"));
const svc = teamsCatalogService({} as any);
const result = await svc.installCatalogTeam("company-1", "core-exec-team");
expect(mockCompanyPortabilityService.importBundle).toHaveBeenCalled();
expect(result.warnings).toEqual(
expect.arrayContaining([
expect.stringContaining("catalog unavailable"),
]),
);
});
it("injects safe claude_local adapter defaults for every bundled agent when no overrides are supplied", async () => {
const svc = teamsCatalogService({} as any);
await svc.installCatalogTeam("company-1", "core-exec-team");
const [importInput] = mockCompanyPortabilityService.importBundle.mock.calls.at(-1)!;
expect(importInput.adapterOverrides).toEqual({
ceo: { adapterType: "claude_local" },
cto: { adapterType: "claude_local" },
qa: { adapterType: "claude_local" },
});
});
it("uses the configured safe adapter default for bundled agents", async () => {
const previousDefault = process.env.PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE;
process.env.PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE = "opencode_local";
try {
const svc = teamsCatalogService({} as any);
await svc.installCatalogTeam("company-1", "core-exec-team");
const [importInput] = mockCompanyPortabilityService.importBundle.mock.calls.at(-1)!;
expect(importInput.adapterOverrides).toEqual({
ceo: { adapterType: "opencode_local" },
cto: { adapterType: "opencode_local" },
qa: { adapterType: "opencode_local" },
});
} finally {
if (previousDefault === undefined) {
delete process.env.PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE;
} else {
process.env.PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE = previousDefault;
}
}
});
it("supplies safe adapter defaults for product-design and product-engineering installs", async () => {
const svc = teamsCatalogService({} as any);
await svc.installCatalogTeam("company-1", "product-design");
const [designInput] = mockCompanyPortabilityService.importBundle.mock.calls.at(-1)!;
expect(designInput.adapterOverrides).toEqual({
"ux-designer": { adapterType: "claude_local" },
});
await svc.installCatalogTeam("company-1", "product-engineering");
const [engineeringInput] = mockCompanyPortabilityService.importBundle.mock.calls.at(-1)!;
expect(engineeringInput.adapterOverrides).toEqual({
cto: { adapterType: "claude_local" },
qa: { adapterType: "claude_local" },
"senior-coder": { adapterType: "claude_local" },
});
});
it("never sends a forbidden process adapter type from the default catalog path", async () => {
const svc = teamsCatalogService({} as any);
await svc.installCatalogTeam("company-1", "core-exec-team");
const [importInput] = mockCompanyPortabilityService.importBundle.mock.calls.at(-1)!;
const adapterTypes = Object.values(importInput.adapterOverrides as Record<string, { adapterType: string }>)
.map((override) => override.adapterType);
expect(adapterTypes).not.toContain("process");
expect(adapterTypes).not.toContain("http");
});
it("preserves an explicit caller adapter override for the affected slug", async () => {
const svc = teamsCatalogService({} as any);
const callerOverrides = {
cto: { adapterType: "opencode_local", adapterConfig: { model: "anthropic/claude-opus-4" } },
};
await svc.installCatalogTeam("company-1", "core-exec-team", {
adapterOverrides: callerOverrides,
});
const [importInput] = mockCompanyPortabilityService.importBundle.mock.calls.at(-1)!;
expect(importInput.adapterOverrides).toEqual({
ceo: { adapterType: "claude_local" },
cto: { adapterType: "opencode_local", adapterConfig: { model: "anthropic/claude-opus-4" } },
qa: { adapterType: "claude_local" },
});
// Caller-supplied object must not be mutated in place.
expect(callerOverrides).toEqual({
cto: { adapterType: "opencode_local", adapterConfig: { model: "anthropic/claude-opus-4" } },
});
});
it("omits the default-adapter warning when every agent has an explicit override", async () => {
const svc = teamsCatalogService({} as any);
const result = await svc.installCatalogTeam("company-1", "core-exec-team", {
adapterOverrides: {
ceo: { adapterType: "opencode_local" },
cto: { adapterType: "opencode_local" },
qa: { adapterType: "opencode_local" },
},
});
expect(result.warnings).not.toEqual(
expect.arrayContaining([expect.stringContaining("default to claude_local")]),
);
});
it("passes install secretValues through to company portability import", async () => {
const svc = teamsCatalogService({} as any);
await svc.installCatalogTeam("company-1", "core-exec-team", {
secretValues: { "agent:ceo:OPENAI_API_KEY": "sk-imported" },
});
expect(mockCompanyPortabilityService.importBundle).toHaveBeenCalledWith(
expect.objectContaining({
secretValues: { "agent:ceo:OPENAI_API_KEY": "sk-imported" },
}),
null,
{ mode: "agent_safe", sourceCompanyId: "company-1" },
);
});
describe("readCatalogTeamProvenance", () => {
it("reads catalogTeam provenance from agent metadata", () => {
expect(
readCatalogTeamProvenance({
paperclip: { catalogTeam: { catalogId: "team-x", catalogKey: "k", originHash: "sha256:1" } },
}),
).toEqual({ catalogId: "team-x", catalogKey: "k", originHash: "sha256:1" });
});
it("returns null when there is no catalogTeam provenance", () => {
expect(readCatalogTeamProvenance(null)).toBeNull();
expect(readCatalogTeamProvenance({})).toBeNull();
expect(readCatalogTeamProvenance({ paperclip: { catalog: { skillKey: "s" } } })).toBeNull();
expect(readCatalogTeamProvenance({ paperclip: { catalogTeam: { originHash: "h" } } })).toBeNull();
});
});
describe("listInstalledCatalogTeams", () => {
it("marks a team out of date when an installed originHash differs from the catalog hash", async () => {
mockAgentService.list.mockResolvedValue([
agentWithCatalogTeam("sha256:stale-hash"),
agentWithCatalogTeam("sha256:stale-hash"),
{ id: "no-provenance", companyId: "company-1", metadata: null },
]);
const svc = teamsCatalogService({} as any);
const installed = await svc.listInstalledCatalogTeams("company-1");
expect(mockAgentService.list).toHaveBeenCalledWith("company-1");
expect(installed).toEqual([
expect.objectContaining({
catalogId: CORE_EXEC_TEAM_ID,
present: true,
currentContentHash: CORE_EXEC_TEAM_HASH,
installedOriginHashes: ["sha256:stale-hash"],
agentCount: 2,
outOfDate: true,
}),
]);
});
it("marks a team up to date when the installed originHash matches the catalog hash", async () => {
mockAgentService.list.mockResolvedValue([agentWithCatalogTeam(CORE_EXEC_TEAM_HASH)]);
const svc = teamsCatalogService({} as any);
const installed = await svc.listInstalledCatalogTeams("company-1");
expect(installed).toHaveLength(1);
expect(installed[0]).toMatchObject({ present: true, outOfDate: false, agentCount: 1 });
});
it("does not flag teams that no longer resolve to a catalog entry", async () => {
mockAgentService.list.mockResolvedValue([
{
id: "removed",
companyId: "company-1",
metadata: { paperclip: { catalogTeam: { catalogId: "paperclipai:bundled:gone:removed", originHash: "sha256:x" } } },
},
]);
const svc = teamsCatalogService({} as any);
const installed = await svc.listInstalledCatalogTeams("company-1");
expect(installed).toEqual([
expect.objectContaining({ present: false, currentContentHash: null, outOfDate: false }),
]);
});
it("returns an empty list when no agents carry catalog-team provenance", async () => {
mockAgentService.list.mockResolvedValue([{ id: "a", companyId: "company-1", metadata: {} }]);
const svc = teamsCatalogService({} as any);
expect(await svc.listInstalledCatalogTeams("company-1")).toEqual([]);
});
});
it("classifies unresolved and unsafe external skill requirements as blocked", () => {
const fakeTeam: CatalogTeam = {
id: "paperclipai:optional:test:unsafe",
key: "paperclipai/optional/test/unsafe",
kind: "optional",
category: "test",
slug: "unsafe",
name: "Unsafe",
description: "Unsafe",
path: "catalog/optional/test/unsafe",
entrypoint: "TEAM.md",
schema: "agentcompanies/v1",
defaultInstall: false,
recommendedForCompanyTypes: [],
tags: [],
counts: { agents: 0, projects: 0, tasks: 0, routines: 0, localSkills: 0, catalogSkills: 0, externalSkillSources: 2 },
rootAgentSlugs: [],
agentSlugs: [],
projectSlugs: [],
requiredSkills: [
{ type: "github", ref: "https://github.com/acme/skill", agentSlugs: ["agent"], resolved: true, sourceLocator: "https://github.com/acme/skill" },
{ type: "catalog", ref: "missing", agentSlugs: ["agent"], resolved: false },
],
envInputs: [],
sourceRefs: [],
files: [],
trustLevel: "external_sources",
compatibility: "compatible",
contentHash: "sha256:test",
};
const result = collectCatalogTeamSkillPreparations(fakeTeam);
expect(result.errors).toEqual([
'External skill source "https://github.com/acme/skill" requires explicit source policy approval.',
'Skill requirement "missing" is unresolved in catalog manifest.',
]);
expect(result.preparations.map((entry) => entry.action)).toEqual(["blocked", "blocked"]);
});
});