mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 21:35:36 +01:00
## Thinking Path > - Paperclip manages AI agents and their work. > - Catalog installation creates agents, projects, and routines in one company. > - The install result includes saved IDs but no links to those resources. > - A chat agent can describe a partial installation without giving the operator a way to inspect it. > - This change returns canonical links for saved resources and documents them for API discovery. > - The operator can use the returned links to inspect the actual installation. ## Linked Issues or Issue Description **What happened?** A catalog install can create the intended resources and report skill warnings, while its response leaves callers to construct board URLs from IDs. A native chat attempt described the resources and warned about an incomplete skill, but omitted resource links in both its initial and repeat replies. **Expected behavior** The installation response should provide canonical company-scoped links for its saved agents, projects, and routines. It must preserve warnings and omit links for rows without saved IDs. **Steps to reproduce** Install a catalog team through the API. Inspect its saved resource rows and response. Before this change, the response has IDs and names but no resource links. **Paperclip version or commit** Parent `e7549ab613` from master. Related #16495 changes comment authorization metadata in the same OpenAPI file; it does not address team installation responses. No duplicate team-link PR was found. ## What Changed - Add typed `resourceLinks` entries with resource kind, saved ID, name, and canonical company-scoped href. - Read the company prefix with one bounded primary-key query before import. Encode URL segments and use saved IDs, including IDs returned for reused resources. - Preserve the portability import result, skill preparations, warnings, permissions, and collision behavior. - Document the response and add service, native API wire, OpenAPI, and real database regression coverage. ## Verification - Parent service regression: 1 failed, 28 passed. The new failure was the missing resource-link response. - Owning service/native wire, route authorization, and OpenAPI suites: 72 passed. - Real PostgreSQL catalog-install suite: 4 passed. Returned links match persisted agent IDs and the company prefix. - Shared and server TypeScript checks passed. Fresh shared/SDK builds and the owning Runner TypeScript prerequisite passed. - UI typecheck, Storybook catalog check, 30 catalog/map controls, and token gates passed. - Actual CI found an omitted required `resourceLinks` field in the existing Storybook install fixture; that fixture is now synchronized. The earlier response-status review correction is also retained: install schema and wire control use actual HTTP 201. Final head `0bca72cf1f7641fd842a04c5a75cae4bcc274f05` passed all 48 normal CI jobs, including `ci / verify`: [run 41773413940](https://github.com/paperclipai/paperclip/actions/runs/41773413940). Greptile is 5/5 with zero unresolved review threads. The optional Paperclip review remains queued and its comment says work is paused. The Superagent security scan is neutral; neither supplies completed assessment credit. - `git diff --check` passed. - Setup failures were retained: incorrect test working directory, sandbox listener denial, missing built SDK/Runner declarations, and native library aliases not hydrated from the package directory. Corrected setup used the owning build and shipped hydration script. No assertions or deadlines were relaxed. - Repository-wide tests/build were not run locally; the full normal CI suite passed. A new native chat attempt has not run for this change. The earlier chat attempt remains failed for missing links. Local response coverage does not prove that a model will include the links in its next reply. ## Risks - This is an additive JSON response field. Manually constructed TypeScript install results must include the new typed field. - The query adds one company-prefix lookup per installation. It selects one scalar by company primary key and runs before mutation. - Supplying links reduces URL construction work but does not guarantee model use. Native qualification is still required. ## Model Used OpenAI `gpt-6.1-sol`, with reasoning, repository inspection, code editing, and tool execution. The host did not expose the model context-window limit. ## 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 required normal CI gates are green; optional incomplete/neutral assessments are disclosed above - [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>
560 lines
24 KiB
TypeScript
560 lines
24 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { readFile } from "node:fs/promises";
|
|
import { parseFrontmatterMarkdown } from "@paperclipai/shared/frontmatter";
|
|
import { executeRunnerApi } from "../services/native-runtime/runner-api-client.js";
|
|
import type { CatalogTeam } from "@paperclipai/shared";
|
|
|
|
const companyPrefix = vi.hoisted(() => vi.fn());
|
|
const testDb = { select: () => ({ from: () => ({ where: () => ({ limit: companyPrefix }) }) }) };
|
|
|
|
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();
|
|
companyPrefix.mockResolvedValue([{ issuePrefix: "ACME" }]);
|
|
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(testDb 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(testDb 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(testDb 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(testDb 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(testDb 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(testDb 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(testDb 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(testDb 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(testDb 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(testDb 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("rejects a missing company before importing resources", async () => {
|
|
companyPrefix.mockResolvedValueOnce([]);
|
|
await expect(teamsCatalogService(testDb as any).installCatalogTeam("company-1", "core-exec-team")).rejects.toMatchObject({ status: 404 });
|
|
expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns scoped saved-resource links while preserving warnings and skipped-resource semantics", async () => {
|
|
mockCompanyPortabilityService.importBundle.mockResolvedValueOnce({
|
|
company: { id: "company-1", name: "Paperclip", action: "unchanged" },
|
|
agents: [{ id: "saved-agent", name: "CTO 2", slug: "cto", action: "created" }, { id: null, name: "Skipped", slug: "skipped", action: "skipped" }],
|
|
projects: [{ id: "saved-project", name: "Engineering", slug: "engineering", action: "updated" }],
|
|
routines: [{ id: "saved-routine", title: "Weekly sync", slug: "weekly", action: "created" }],
|
|
envInputs: [], warnings: ["required skill unavailable"],
|
|
});
|
|
const result = await teamsCatalogService(testDb as any).installCatalogTeam("company-1", "core-exec-team");
|
|
expect(result.resourceLinks).toEqual([
|
|
{ kind: "agent", id: "saved-agent", name: "CTO 2", href: "/ACME/agents/saved-agent" },
|
|
{ kind: "project", id: "saved-project", name: "Engineering", href: "/ACME/projects/saved-project" },
|
|
{ kind: "routine", id: "saved-routine", name: "Weekly sync", href: "/ACME/routines/saved-routine" },
|
|
]);
|
|
const wire = await executeRunnerApi({ operationId: "POST /api/companies/{companyId}/teams/catalog/{catalogId}/install",
|
|
pathParams: { catalogId: "core-exec-team" }, body: {} },
|
|
{ companyId: "company-1", issueId: "issue-1", issueIdentifier: "ACME-1", runId: "run-1", workMode: "standard" },
|
|
{ apiUrl: "http://fixture.invalid", token: "private-test-token", fetch: async () => Response.json(result, { status: 201 }),
|
|
readFile: async () => { throw new Error("not used"); }, saveResponse: async () => { throw new Error("unexpected artifact"); } });
|
|
expect(wire).toMatchObject({ ok: true, status: 201, data: { resourceLinks: result.resourceLinks, warnings: result.warnings } });
|
|
expect(result.warnings).toContain("required skill unavailable");
|
|
expect(result.portabilityImport.agents[1].id).toBeNull();
|
|
});
|
|
|
|
it("surfaces post-import catalog skill install failures as warnings", async () => {
|
|
mockCompanySkillService.installFromCatalog.mockRejectedValueOnce(new Error("catalog unavailable"));
|
|
const svc = teamsCatalogService(testDb 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(testDb 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(testDb 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(testDb 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(testDb 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(testDb 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(testDb 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(testDb 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(testDb 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(testDb 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(testDb 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(testDb 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"]);
|
|
});
|
|
});
|