mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-14 08:26:46 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent adapters control how Paperclip starts and resumes an agent runtime. > - Paperclip Runner is an experimental Rust runtime and must stay opt-in. > - The server already rejected new runner selections when the flag was off. > - Some setup and onboarding views did not enforce the same boundary. > - This pull request exposes the existing flag and applies it to every new setup path. > - The benefit is a safe rollout with unchanged legacy onboarding and recoverable existing native runs. ## Linked Issues or Issue Description **What existing behavior does this improve?** This improves experimental adapter selection in Settings, onboarding, new-agent setup, invite setup, and company import. **Subsystem affected** Cross-cutting: the React UI and the server onboarding seed service. **Current behavior** The server defaulted Paperclip Runner to off, but Settings did not expose the flag. First-run onboarding could show the runner after opt-in. A direct new-agent URL and some setup pickers could also reveal native runner configuration before the availability check completed. **Proposed behavior** Settings has a default-off Paperclip Runner toggle. Explicit agent configuration shows the runner only after the server reports that the flag is enabled. First-run and invite onboarding always use legacy adapters. Existing native agents and runs remain readable and recoverable. **Reason and benefit** This keeps the experimental runtime out of normal onboarding. It also gives administrators one clear opt-in before users can create a native runner agent. **Breaking changes** None. Legacy adapter selection and execution stay unchanged. Existing native records remain available. ## What Changed - Added the Paperclip Runner opt-in to Experimental Settings. - Refreshed adapter availability after the setting changes. - Kept UI and server-seeded onboarding on legacy adapters. - Made native runner choices fail closed in new-agent, invite, and import setup. - Preserved edit and recovery behavior for existing native agents and runs. - Added focused regression tests for flag-off and flag-on behavior. ## Verification - GitHub Actions will run the repository test, typecheck, build, and policy gates. - Focused tests cover Settings, onboarding, agent creation, invite setup, import setup, and server-seeded onboarding. - No local test suite was run, per the maintainer request to use GitHub Actions for verification. - `git diff --check` passes. ## Risks Low risk. The change narrows new adapter selection only. The server remains the final enforcement point. Existing native records do not depend on the current flag value for read or recovery behavior. > 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, GPT-5, with reasoning, tool use, and code execution. ## 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 - [ ] 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 - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
485 lines
16 KiB
TypeScript
485 lines
16 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type {
|
|
CatalogTeam,
|
|
CatalogTeamImportPreviewResult,
|
|
} from "@paperclipai/shared";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
TeamCatalog,
|
|
listTeamInstallAdapterTypes,
|
|
parseTeamRoute,
|
|
resolveTeamInstallAdapterType,
|
|
teamRoute,
|
|
} from "./TeamCatalog";
|
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
|
|
const mockTeamCatalogApi = vi.hoisted(() => ({
|
|
catalogList: vi.fn(),
|
|
catalogDetail: vi.fn(),
|
|
catalogFile: vi.fn(),
|
|
preview: vi.fn(),
|
|
install: vi.fn(),
|
|
installed: vi.fn(),
|
|
}));
|
|
|
|
const mockAgentsApi = vi.hoisted(() => ({
|
|
list: vi.fn(),
|
|
}));
|
|
|
|
const mockPushToast = vi.hoisted(() => vi.fn());
|
|
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
|
const mockNavigate = vi.hoisted(() => vi.fn());
|
|
const mockAdapterAvailability = vi.hoisted(() => ({
|
|
disabled: new Set<string>(["paperclip_runner"]),
|
|
loaded: true,
|
|
}));
|
|
|
|
vi.mock("../api/teamCatalog", () => ({ teamCatalogApi: mockTeamCatalogApi }));
|
|
vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi }));
|
|
vi.mock("../adapters/use-disabled-adapters", () => ({
|
|
useDisabledAdaptersSync: () => mockAdapterAvailability.disabled,
|
|
useAdapterRegistryLoaded: () => mockAdapterAvailability.loaded,
|
|
}));
|
|
|
|
vi.mock("../components/MarkdownBody", () => ({
|
|
MarkdownBody: ({ children }: { children: string }) => <div>{children}</div>,
|
|
}));
|
|
|
|
vi.mock("../context/BreadcrumbContext", () => ({
|
|
useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }),
|
|
}));
|
|
|
|
vi.mock("../context/ToastContext", () => ({
|
|
useToastActions: () => ({ pushToast: mockPushToast }),
|
|
}));
|
|
|
|
vi.mock("../context/CompanyContext", () => ({
|
|
useCompany: () => ({ selectedCompanyId: "company-1" }),
|
|
}));
|
|
|
|
// Drive the route deterministically: the team is preselected so the detail pane
|
|
// renders without depending on the auto-select navigation effect.
|
|
let currentRoute = "team-no-deps";
|
|
const mockSearchParams = new URLSearchParams();
|
|
vi.mock("@/lib/router", () => ({
|
|
useParams: () => ({ "*": currentRoute }),
|
|
useNavigate: () => mockNavigate,
|
|
useSearchParams: () => [mockSearchParams, vi.fn()],
|
|
}));
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
// cmdk (Command) and Radix rely on browser APIs jsdom does not implement.
|
|
class ResizeObserverStub {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).ResizeObserver = (globalThis as any).ResizeObserver ?? ResizeObserverStub;
|
|
// jsdom has no matchMedia. Default to desktop (min-width queries match, max-width don't).
|
|
if (typeof window !== "undefined" && !window.matchMedia) {
|
|
window.matchMedia = ((query: string) => ({
|
|
matches: /min-width/.test(query),
|
|
media: query,
|
|
onchange: null,
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
addListener: () => {},
|
|
removeListener: () => {},
|
|
dispatchEvent: () => false,
|
|
})) as typeof window.matchMedia;
|
|
}
|
|
if (typeof Element !== "undefined" && !Element.prototype.scrollIntoView) {
|
|
Element.prototype.scrollIntoView = () => {};
|
|
}
|
|
|
|
describe("TeamCatalog routes", () => {
|
|
it("round-trips file paths containing literal tildes", () => {
|
|
const route = teamRoute("paperclipai/bundled/test/team", "agents/a~b/AGENTS.md");
|
|
|
|
expect(route).toBe("/teams-catalog/paperclipai%2Fbundled%2Ftest%2Fteam/files/agents%7Ea~b%7EAGENTS.md");
|
|
expect(parseTeamRoute(route.replace("/teams-catalog/", ""))).toEqual({
|
|
catalogRef: "paperclipai/bundled/test/team",
|
|
filePath: "agents/a~b/AGENTS.md",
|
|
});
|
|
});
|
|
|
|
it("fails closed for Paperclip Runner until adapter availability is loaded and enabled", () => {
|
|
expect(listTeamInstallAdapterTypes(new Set(), false)).not.toContain("paperclip_runner");
|
|
expect(listTeamInstallAdapterTypes(new Set(), false)).not.toContain("process");
|
|
expect(listTeamInstallAdapterTypes(new Set(), false)).not.toContain("http");
|
|
expect(listTeamInstallAdapterTypes(new Set(["paperclip_runner"]), true))
|
|
.not.toContain("paperclip_runner");
|
|
expect(listTeamInstallAdapterTypes(new Set(), true)).toContain("paperclip_runner");
|
|
expect(resolveTeamInstallAdapterType("paperclip_runner", ["claude_local", "codex_local"]))
|
|
.toBe("claude_local");
|
|
expect(resolveTeamInstallAdapterType("paperclip_runner", ["codex_local"]))
|
|
.toBe("codex_local");
|
|
expect(resolveTeamInstallAdapterType("paperclip_runner", []))
|
|
.toBeNull();
|
|
});
|
|
});
|
|
|
|
async function act(callback: () => void | Promise<void>) {
|
|
let result: void | Promise<void> = undefined;
|
|
flushSync(() => {
|
|
result = callback();
|
|
});
|
|
await result;
|
|
}
|
|
|
|
async function flushReact() {
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
});
|
|
}
|
|
|
|
function makeTeam(overrides: Partial<CatalogTeam> = {}): CatalogTeam {
|
|
return {
|
|
id: "team-no-deps",
|
|
key: "paperclipai/bundled/company-defaults/team-no-deps",
|
|
kind: "bundled",
|
|
category: "company-defaults",
|
|
slug: "team-no-deps",
|
|
name: "Core Exec Team",
|
|
description: "A starter executive team.",
|
|
path: "catalog/bundled/company-defaults/team-no-deps",
|
|
entrypoint: "TEAM.md",
|
|
schema: "agentcompanies/v1",
|
|
defaultInstall: true,
|
|
recommendedForCompanyTypes: [],
|
|
tags: ["exec"],
|
|
counts: {
|
|
agents: 2,
|
|
projects: 1,
|
|
tasks: 0,
|
|
routines: 1,
|
|
localSkills: 0,
|
|
catalogSkills: 0,
|
|
externalSkillSources: 0,
|
|
},
|
|
rootAgentSlugs: [],
|
|
agentSlugs: ["ceo", "cto"],
|
|
projectSlugs: ["launch"],
|
|
requiredSkills: [],
|
|
envInputs: [],
|
|
sourceRefs: [],
|
|
files: [{ path: "TEAM.md", kind: "team", sizeBytes: 100, sha256: "abc" }],
|
|
trustLevel: "markdown_only",
|
|
compatibility: "compatible",
|
|
contentHash: "sha256:deadbeefdeadbeefdeadbeef",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makePreview(): CatalogTeamImportPreviewResult {
|
|
return {
|
|
team: makeTeam(),
|
|
portabilityPreview: {
|
|
include: { company: false, agents: true, projects: true, issues: false, skills: true },
|
|
targetCompanyId: "company-1",
|
|
targetCompanyName: "Paperclip",
|
|
collisionStrategy: "rename",
|
|
selectedAgentSlugs: ["ceo", "cto"],
|
|
plan: {
|
|
companyAction: "none",
|
|
agentPlans: [
|
|
{ slug: "ceo", action: "create", plannedName: "CEO", existingAgentId: null, reason: null },
|
|
{ slug: "cto", action: "create", plannedName: "CTO", existingAgentId: null, reason: null },
|
|
],
|
|
projectPlans: [
|
|
{ slug: "launch", action: "create", plannedName: "Launch", existingProjectId: null, reason: null },
|
|
],
|
|
issuePlans: [],
|
|
},
|
|
manifest: {
|
|
schemaVersion: 1,
|
|
generatedAt: "2026-06-03T00:00:00.000Z",
|
|
source: null,
|
|
includes: { company: false, agents: true, projects: true, issues: false, skills: true },
|
|
company: null,
|
|
sidebar: null,
|
|
agents: [],
|
|
skills: [],
|
|
projects: [],
|
|
issues: [],
|
|
envInputs: [],
|
|
},
|
|
files: {},
|
|
envInputs: [],
|
|
warnings: [],
|
|
errors: [],
|
|
},
|
|
skillPreparations: [],
|
|
warnings: [],
|
|
errors: [],
|
|
};
|
|
}
|
|
|
|
function setInputValue(input: HTMLInputElement, value: string) {
|
|
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
|
setter?.call(input, value);
|
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
}
|
|
|
|
function findButton(label: string): HTMLButtonElement | undefined {
|
|
return Array.from(document.querySelectorAll("button")).find((b) =>
|
|
(b.textContent ?? "").includes(label),
|
|
) as HTMLButtonElement | undefined;
|
|
}
|
|
|
|
describe("TeamCatalog install preview path", () => {
|
|
let container: HTMLDivElement;
|
|
|
|
beforeEach(() => {
|
|
mockAdapterAvailability.disabled = new Set(["paperclip_runner"]);
|
|
mockAdapterAvailability.loaded = true;
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
currentRoute = "team-no-deps";
|
|
mockAgentsApi.list.mockResolvedValue([]);
|
|
mockTeamCatalogApi.installed.mockResolvedValue([]);
|
|
mockTeamCatalogApi.catalogList.mockResolvedValue([makeTeam()]);
|
|
mockTeamCatalogApi.preview.mockResolvedValue(makePreview());
|
|
mockTeamCatalogApi.install.mockResolvedValue({
|
|
team: makeTeam(),
|
|
portabilityImport: {
|
|
company: { id: "company-1", name: "Paperclip", action: "unchanged" },
|
|
agents: [],
|
|
projects: [],
|
|
envInputs: [],
|
|
warnings: [],
|
|
},
|
|
skillPreparations: [],
|
|
warnings: [],
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
container.remove();
|
|
document.body.innerHTML = "";
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
async function renderPage() {
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<TooltipProvider>
|
|
<TeamCatalog />
|
|
</TooltipProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
return root;
|
|
}
|
|
|
|
it("renders the detail pane for the selected team", async () => {
|
|
await renderPage();
|
|
expect(document.body.textContent).toContain("Core Exec Team");
|
|
expect(document.body.textContent).toContain("A starter executive team.");
|
|
// summary grid counts
|
|
expect(document.body.textContent).toContain("Agents");
|
|
expect(document.body.textContent).toContain("Projects");
|
|
});
|
|
|
|
it("opens the installer, fetches the preview, and submits the install", async () => {
|
|
await renderPage();
|
|
|
|
// Open the installer from the detail CTA.
|
|
const installCta = findButton("Install team");
|
|
expect(installCta).toBeTruthy();
|
|
await act(async () => {
|
|
installCta!.click();
|
|
});
|
|
await flushReact();
|
|
|
|
// The wizard is single-step (no deps) → lands on the preview step and
|
|
// auto-loads the categorized preview.
|
|
expect(mockTeamCatalogApi.preview).toHaveBeenCalledTimes(1);
|
|
expect(mockTeamCatalogApi.preview).toHaveBeenCalledWith(
|
|
"company-1",
|
|
"team-no-deps",
|
|
expect.objectContaining({ collisionStrategy: "rename" }),
|
|
);
|
|
expect(document.body.textContent).toContain("Summary");
|
|
// categorized plan rows
|
|
expect(document.body.textContent?.toLowerCase()).toContain("ceo");
|
|
expect(document.body.textContent?.toLowerCase()).toContain("launch");
|
|
|
|
// Submit install from the footer.
|
|
const submit = Array.from(document.querySelectorAll("button")).filter((b) =>
|
|
(b.textContent ?? "").includes("Install team"),
|
|
);
|
|
// Last "Install team" is the wizard footer submit.
|
|
await act(async () => {
|
|
submit[submit.length - 1].click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockTeamCatalogApi.install).toHaveBeenCalledTimes(1);
|
|
expect(document.body.textContent).toContain("Team installed");
|
|
});
|
|
|
|
it("requires and submits Step 4 secret values", async () => {
|
|
const preview = makePreview();
|
|
preview.portabilityPreview.envInputs = [
|
|
{
|
|
key: "OPENAI_API_KEY",
|
|
description: "OpenAI API key",
|
|
agentSlug: "ceo",
|
|
projectSlug: null,
|
|
kind: "secret",
|
|
requirement: "required",
|
|
defaultValue: null,
|
|
portability: "portable",
|
|
},
|
|
];
|
|
mockTeamCatalogApi.preview.mockResolvedValue(preview);
|
|
|
|
await renderPage();
|
|
|
|
const installCta = findButton("Install team");
|
|
await act(async () => {
|
|
installCta!.click();
|
|
});
|
|
await flushReact();
|
|
|
|
const footerInstall = Array.from(document.querySelectorAll("button")).filter((b) =>
|
|
(b.textContent ?? "").includes("Install team"),
|
|
).at(-1) as HTMLButtonElement;
|
|
expect(footerInstall.disabled).toBe(true);
|
|
expect(document.body.textContent).toContain("Required secrets missing: 1");
|
|
|
|
const secretInput = document.querySelector('input[aria-label="OPENAI_API_KEY value"]') as HTMLInputElement;
|
|
expect(secretInput).toBeTruthy();
|
|
await act(async () => {
|
|
setInputValue(secretInput, "sk-imported");
|
|
});
|
|
await flushReact();
|
|
|
|
expect(footerInstall.disabled).toBe(false);
|
|
await act(async () => {
|
|
footerInstall.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockTeamCatalogApi.install).toHaveBeenCalledWith(
|
|
"company-1",
|
|
"team-no-deps",
|
|
expect.objectContaining({
|
|
secretValues: {
|
|
"agent:ceo:OPENAI_API_KEY": "sk-imported",
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("requires a target manager before continuing when the team has root agents", async () => {
|
|
currentRoute = "team-with-root";
|
|
mockTeamCatalogApi.catalogList.mockResolvedValue([
|
|
makeTeam({ id: "team-with-root", slug: "team-with-root", rootAgentSlugs: ["ceo"], agentSlugs: ["ceo", "cto"] }),
|
|
]);
|
|
mockAgentsApi.list.mockResolvedValue([
|
|
{
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
name: "Founder",
|
|
urlKey: "founder",
|
|
role: "ceo",
|
|
title: null,
|
|
icon: null,
|
|
status: "active",
|
|
reportsTo: null,
|
|
capabilities: null,
|
|
adapterType: "claude_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
budgetMonthlyCents: 0,
|
|
spentMonthlyCents: 0,
|
|
pauseReason: null,
|
|
pausedAt: null,
|
|
permissions: {},
|
|
lastHeartbeatAt: null,
|
|
metadata: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
]);
|
|
await renderPage();
|
|
|
|
const installCta = findButton("Install team");
|
|
await act(async () => {
|
|
installCta!.click();
|
|
});
|
|
await flushReact();
|
|
|
|
// First step is target manager; Continue is disabled until a manager is chosen.
|
|
expect(document.body.textContent).toContain("root agents need a manager");
|
|
const continueBtn = findButton("Continue");
|
|
expect(continueBtn).toBeTruthy();
|
|
expect(continueBtn!.disabled).toBe(true);
|
|
// Preview has not been requested yet (still on step 1).
|
|
expect(mockTeamCatalogApi.preview).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("surfaces the INSTALLED group, out-of-date badge, and Re-install CTA from the server signal", async () => {
|
|
mockTeamCatalogApi.installed.mockResolvedValue([
|
|
{
|
|
catalogId: "team-no-deps",
|
|
catalogKey: "paperclipai/bundled/company-defaults/team-no-deps",
|
|
present: true,
|
|
currentContentHash: "sha256:deadbeefdeadbeefdeadbeef",
|
|
installedOriginHashes: ["sha256:stale"],
|
|
agentCount: 2,
|
|
outOfDate: true,
|
|
},
|
|
]);
|
|
|
|
await renderPage();
|
|
|
|
// List group header reflects the installed team, not Bundled.
|
|
expect(document.body.textContent).toContain("Installed · 1");
|
|
expect(document.body.textContent).not.toContain("Bundled · 1");
|
|
|
|
// Detail header chip + Re-install CTA replace the plain Install button.
|
|
expect(document.body.textContent).toContain("Update available");
|
|
expect(findButton("Re-install latest")).toBeTruthy();
|
|
expect(findButton("Install team")).toBeFalsy();
|
|
|
|
// The out-of-date badge in the list row exposes an accessible label.
|
|
expect(document.querySelector('[aria-label="Update available"]')).toBeTruthy();
|
|
});
|
|
|
|
it("renders an Installed badge (no update chip) when the installed team is current", async () => {
|
|
mockTeamCatalogApi.installed.mockResolvedValue([
|
|
{
|
|
catalogId: "team-no-deps",
|
|
catalogKey: "paperclipai/bundled/company-defaults/team-no-deps",
|
|
present: true,
|
|
currentContentHash: "sha256:deadbeefdeadbeefdeadbeef",
|
|
installedOriginHashes: ["sha256:deadbeefdeadbeefdeadbeef"],
|
|
agentCount: 2,
|
|
outOfDate: false,
|
|
},
|
|
]);
|
|
|
|
await renderPage();
|
|
|
|
expect(document.body.textContent).toContain("Installed · 1");
|
|
expect(document.body.textContent).toContain("Installed");
|
|
expect(document.body.textContent).not.toContain("Update available");
|
|
expect(findButton("Re-install latest")).toBeTruthy();
|
|
});
|
|
});
|