mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 23:39:21 +02:00
## Thinking Path > - Paperclip manages agents that use different model providers and adapters. > - Paperclip must keep agent execution rules clear and predictable. > - The cheap-model profile added a second execution mode across adapters, task recovery, APIs, and the UI. > - That mode increased configuration and recovery complexity. > - This pull request removes the cheap-model profile as a product feature. > - The benefit is one model-selection path for normal work and recovery work. ## Linked Issues or Issue Description **What existing behavior does this improve?** This change simplifies model selection across agent configuration, task execution, recovery, and adapter capabilities. **Current behavior** Paperclip exposes cheap-model profiles in adapter metadata, agent runtime configuration, task overrides, recovery rules, APIs, and the board UI. Recovery work can select a different model profile from the agent's configured model. **Proposed behavior** Paperclip uses the agent's configured model for normal work and recovery work. Status-only recovery stays limited to coordination work. The API rejects legacy model-profile configuration. A migration removes stored model-profile values from existing agent, issue, and historical revision records. **Reason and benefit** One model path reduces configuration, API, UI, and recovery complexity. It also prevents status recovery from becoming a separate product-level model-routing feature. **Breaking changes** This change removes model-profile fields and adapter capability metadata. Existing stored model-profile values are removed by an idempotent migration. The validators reject new legacy profile values with clear errors. ## What Changed - Removed model-profile types, adapter capabilities, API fields, and model selection logic. - Removed cheap-model controls from agent and task UI surfaces. - Kept status-only recovery limited to coordination context while normal continuations use the configured agent model. - Added an idempotent migration that removes stored model-profile values from agents, issues, and configuration revisions without changing issue update timestamps. - Updated tests and product documentation for the single-model behavior. ## Verification - `pnpm check:token-gates` passes. - `pnpm -r typecheck` passes. - `pnpm build` passes. - `pnpm test:run` completed with 5,607 passing tests and 8 environment-sensitive failures in unrelated fixed-port and database-deadlock suites. The same failures repeated in an isolated rerun. CI is the final clean-room result. ## Risks - This is an intentional breaking change for clients that send model-profile fields. - The migration changes legacy agent, issue, and configuration-revision JSON. It is idempotent and preserves unrelated fields and issue update timestamps. - The change is cross-cutting because the removed feature existed in adapters, shared contracts, the server, plugins, and the UI. > 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 with `gpt-5`. Reasoning and tool use were enabled. The runtime did not expose the 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) - [ ] 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 - [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>
350 lines
11 KiB
TypeScript
350 lines
11 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import type { ReactNode } from "react";
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type {
|
|
AgentDesiredSkillEntry,
|
|
CompanySkillDetail,
|
|
CompanySkillUsageAgent,
|
|
CompanySkillVersion,
|
|
} from "@paperclipai/shared";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
AgentsUsingSkillBadge,
|
|
AgentsUsingSkillDialog,
|
|
} from "./AgentsUsingSkillDialog";
|
|
|
|
const mockAgentsApi = vi.hoisted(() => ({
|
|
list: vi.fn(),
|
|
skills: vi.fn(),
|
|
syncSkills: vi.fn(),
|
|
}));
|
|
const mockCompanySkillsApi = vi.hoisted(() => ({
|
|
versions: vi.fn(),
|
|
}));
|
|
const mockPushToast = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("@/lib/router", () => ({
|
|
Link: ({ children, to }: { children: ReactNode; to: string }) => <a href={to}>{children}</a>,
|
|
}));
|
|
vi.mock("@/api/agents", () => ({ agentsApi: mockAgentsApi }));
|
|
vi.mock("@/api/companySkills", () => ({ companySkillsApi: mockCompanySkillsApi }));
|
|
vi.mock("@/adapters/use-adapter-capabilities", () => ({
|
|
useAdapterCapabilities: () => () => ({
|
|
supportsInstructionsBundle: true,
|
|
supportsSkills: true,
|
|
supportsLocalAgentJwt: true,
|
|
requiresMaterializedRuntimeSkills: false,
|
|
}),
|
|
}));
|
|
vi.mock("@/context/ToastContext", () => ({
|
|
useOptionalToastActions: () => ({ pushToast: mockPushToast }),
|
|
}));
|
|
|
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
let root: Root | null = null;
|
|
let container: HTMLDivElement | null = null;
|
|
|
|
// React's `act` is not usable in this vitest setup (known gotcha); drive
|
|
// updates through flushSync like SkillStudio.test.tsx does.
|
|
async function act(callback: () => void | Promise<void>) {
|
|
let result: void | Promise<void> = undefined;
|
|
flushSync(() => {
|
|
result = callback();
|
|
});
|
|
await result;
|
|
}
|
|
|
|
async function flush() {
|
|
await Promise.resolve();
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
}
|
|
|
|
function teardown() {
|
|
if (root) flushSync(() => root?.unmount());
|
|
container?.remove();
|
|
root = null;
|
|
container = null;
|
|
// Radix Dialog portals to document.body; drop any leftovers between renders.
|
|
document.body.innerHTML = "";
|
|
}
|
|
|
|
async function renderNode(node: ReactNode) {
|
|
teardown();
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
await act(async () => {
|
|
root?.render(<QueryClientProvider client={queryClient}>{node}</QueryClientProvider>);
|
|
});
|
|
await flush();
|
|
}
|
|
|
|
async function click(el: Element) {
|
|
await act(async () => {
|
|
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
}
|
|
|
|
function findButton(name: string): HTMLButtonElement {
|
|
const match = Array.from(document.body.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.trim() === name,
|
|
) as HTMLButtonElement | undefined;
|
|
if (!match) throw new Error(`No button labelled "${name}"`);
|
|
return match;
|
|
}
|
|
|
|
function makeAgent(overrides: Partial<CompanySkillUsageAgent> = {}): CompanySkillUsageAgent {
|
|
return {
|
|
id: "agent-1",
|
|
name: "Reviewer",
|
|
urlKey: "reviewer",
|
|
adapterType: "claude_local",
|
|
desired: true,
|
|
actualState: null,
|
|
versionId: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeVersion(overrides: Partial<CompanySkillVersion> = {}): CompanySkillVersion {
|
|
return {
|
|
id: "ver-1",
|
|
companyId: "company-1",
|
|
companySkillId: "skill-1",
|
|
revisionNumber: 1,
|
|
label: null,
|
|
releaseId: null,
|
|
releaseName: null,
|
|
releasedAt: null,
|
|
fileInventory: [],
|
|
authorAgentId: null,
|
|
authorUserId: null,
|
|
createdAt: new Date("2026-01-01T00:00:00Z"),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeSkill(overrides: Partial<CompanySkillDetail> = {}): CompanySkillDetail {
|
|
return {
|
|
id: "skill-1",
|
|
companyId: "company-1",
|
|
key: "paperclip/demo",
|
|
slug: "demo",
|
|
name: "Demo Skill",
|
|
description: null,
|
|
markdown: "",
|
|
sourceType: "local_path",
|
|
sourceLocator: null,
|
|
sourceRef: null,
|
|
trustLevel: "markdown_only",
|
|
compatibility: "compatible",
|
|
fileInventory: [],
|
|
iconUrl: null,
|
|
color: null,
|
|
tagline: null,
|
|
authorName: null,
|
|
homepageUrl: null,
|
|
categories: [],
|
|
sharingScope: "company",
|
|
publicShareToken: null,
|
|
forkedFromSkillId: null,
|
|
forkedFromCompanyId: null,
|
|
starCount: 0,
|
|
installCount: 0,
|
|
forkCount: 0,
|
|
currentVersionId: "ver-3",
|
|
metadata: null,
|
|
createdAt: new Date("2026-01-01T00:00:00Z"),
|
|
updatedAt: new Date("2026-01-02T00:00:00Z"),
|
|
attachedAgentCount: 0,
|
|
usedByAgents: [],
|
|
editable: true,
|
|
editableReason: null,
|
|
sourceLabel: "Local",
|
|
sourceBadge: "local",
|
|
sourcePath: null,
|
|
currentVersion: makeVersion({ id: "ver-3", revisionNumber: 3 }),
|
|
starredByCurrentActor: false,
|
|
existingForks: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockAgentsApi.list.mockResolvedValue([]);
|
|
mockAgentsApi.skills.mockResolvedValue({
|
|
adapterType: "claude_local",
|
|
supported: true,
|
|
mode: "managed",
|
|
desiredSkills: [],
|
|
desiredSkillEntries: [],
|
|
entries: [],
|
|
warnings: [],
|
|
});
|
|
mockAgentsApi.syncSkills.mockResolvedValue({});
|
|
mockCompanySkillsApi.versions.mockResolvedValue([]);
|
|
mockPushToast.mockReset();
|
|
});
|
|
|
|
afterEach(() => {
|
|
teardown();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("AgentsUsingSkillBadge", () => {
|
|
it("renders a muted '0 agents' button with an a11y label when unused", async () => {
|
|
await renderNode(<AgentsUsingSkillBadge companyId="company-1" skill={makeSkill()} />);
|
|
const badge = findButton("0 agents");
|
|
expect(badge).toBeDefined();
|
|
expect(badge?.getAttribute("aria-label")).toBe("0 agents use this skill");
|
|
expect(badge?.className).toContain("text-muted-foreground");
|
|
});
|
|
|
|
it("pluralizes and opens the dialog on click", async () => {
|
|
const skill = makeSkill({
|
|
usedByAgents: [makeAgent(), makeAgent({ id: "agent-2", name: "Coder", urlKey: "coder" })],
|
|
});
|
|
await renderNode(<AgentsUsingSkillBadge companyId="company-1" skill={skill} />);
|
|
const badge = findButton("2 agents");
|
|
expect(badge?.getAttribute("aria-label")).toBe("2 agents use this skill");
|
|
await click(badge!);
|
|
expect(document.body.textContent).toContain("Agents using Demo Skill");
|
|
expect(document.body.textContent).toContain("Reviewer");
|
|
expect(document.body.textContent).toContain("Coder");
|
|
});
|
|
});
|
|
|
|
describe("AgentsUsingSkillDialog", () => {
|
|
it("shows a version select with Latest (vN) and a static '—' when no version history", async () => {
|
|
mockCompanySkillsApi.versions.mockResolvedValue([
|
|
makeVersion({ id: "ver-3", revisionNumber: 3 }),
|
|
makeVersion({ id: "ver-2", revisionNumber: 2 }),
|
|
]);
|
|
const skill = makeSkill({ usedByAgents: [makeAgent()] });
|
|
await renderNode(
|
|
<AgentsUsingSkillDialog open onOpenChange={() => {}} companyId="company-1" skill={skill} />,
|
|
);
|
|
const select = document.body.querySelector("select");
|
|
expect(select).not.toBeNull();
|
|
expect(select?.textContent).toContain("Latest (v3)");
|
|
expect(select?.textContent).toContain("v2");
|
|
|
|
// No version history → "—" and no select.
|
|
await renderNode(
|
|
<AgentsUsingSkillDialog
|
|
open
|
|
onOpenChange={() => {}}
|
|
companyId="company-1"
|
|
skill={makeSkill({ currentVersion: null, currentVersionId: null, usedByAgents: [makeAgent()] })}
|
|
/>,
|
|
);
|
|
// The second render's dialog is the last-opened; its row shows an em dash.
|
|
expect(document.body.textContent).toContain("—");
|
|
});
|
|
|
|
it("shows a 'behind latest' hint for a stale pin", async () => {
|
|
mockCompanySkillsApi.versions.mockResolvedValue([
|
|
makeVersion({ id: "ver-3", revisionNumber: 3 }),
|
|
makeVersion({ id: "ver-1", revisionNumber: 1 }),
|
|
]);
|
|
const skill = makeSkill({ usedByAgents: [makeAgent({ versionId: "ver-1" })] });
|
|
await renderNode(
|
|
<AgentsUsingSkillDialog open onOpenChange={() => {}} companyId="company-1" skill={skill} />,
|
|
);
|
|
expect(document.body.textContent).toContain("2 versions behind latest");
|
|
});
|
|
|
|
it("removes via GET-then-sync and preserves the agent's other skills", async () => {
|
|
mockAgentsApi.skills.mockResolvedValue({
|
|
adapterType: "claude_local",
|
|
supported: true,
|
|
mode: "managed",
|
|
desiredSkills: ["paperclip/demo", "paperclip/other"],
|
|
desiredSkillEntries: [
|
|
{ key: "paperclip/demo", versionId: null },
|
|
{ key: "paperclip/other", versionId: null },
|
|
] satisfies AgentDesiredSkillEntry[],
|
|
entries: [],
|
|
warnings: [],
|
|
});
|
|
const skill = makeSkill({ usedByAgents: [makeAgent()] });
|
|
await renderNode(
|
|
<AgentsUsingSkillDialog open onOpenChange={() => {}} companyId="company-1" skill={skill} />,
|
|
);
|
|
|
|
// The trash button has no text; grab it by aria-label.
|
|
const trash = document.body.querySelector<HTMLButtonElement>(
|
|
'button[aria-label^="Remove this skill from"]',
|
|
);
|
|
await click(trash!);
|
|
const confirm = document.body.querySelector<HTMLButtonElement>(
|
|
'button[aria-label^="Confirm removing this skill from"]',
|
|
);
|
|
await click(confirm!);
|
|
|
|
expect(mockAgentsApi.skills).toHaveBeenCalledWith("agent-1", "company-1");
|
|
expect(mockAgentsApi.syncSkills).toHaveBeenCalledTimes(1);
|
|
const [, sentEntries] = mockAgentsApi.syncSkills.mock.calls[0];
|
|
expect(sentEntries).toEqual([{ key: "paperclip/other", versionId: null }]);
|
|
});
|
|
|
|
it("pins a version by sending the full set with the target repinned", async () => {
|
|
mockCompanySkillsApi.versions.mockResolvedValue([
|
|
makeVersion({ id: "ver-3", revisionNumber: 3 }),
|
|
makeVersion({ id: "ver-2", revisionNumber: 2 }),
|
|
]);
|
|
mockAgentsApi.skills.mockResolvedValue({
|
|
adapterType: "claude_local",
|
|
supported: true,
|
|
mode: "managed",
|
|
desiredSkills: ["paperclip/demo", "paperclip/other"],
|
|
desiredSkillEntries: [
|
|
{ key: "paperclip/demo", versionId: null },
|
|
{ key: "paperclip/other", versionId: null },
|
|
] satisfies AgentDesiredSkillEntry[],
|
|
entries: [],
|
|
warnings: [],
|
|
});
|
|
const skill = makeSkill({ usedByAgents: [makeAgent()] });
|
|
await renderNode(
|
|
<AgentsUsingSkillDialog open onOpenChange={() => {}} companyId="company-1" skill={skill} />,
|
|
);
|
|
const select = document.body.querySelector<HTMLSelectElement>("select");
|
|
await act(async () => {
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set;
|
|
setter?.call(select, "ver-2");
|
|
select?.dispatchEvent(new Event("change", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(mockAgentsApi.syncSkills).toHaveBeenCalledTimes(1);
|
|
const [, sentEntries] = mockAgentsApi.syncSkills.mock.calls[0];
|
|
expect(sentEntries).toContainEqual({ key: "paperclip/other", versionId: null });
|
|
expect(sentEntries).toContainEqual({ key: "paperclip/demo", versionId: "ver-2" });
|
|
});
|
|
|
|
it("hides mutating controls in read-only mode but keeps the roster", async () => {
|
|
const skill = makeSkill({ usedByAgents: [makeAgent()] });
|
|
await renderNode(
|
|
<AgentsUsingSkillDialog
|
|
open
|
|
onOpenChange={() => {}}
|
|
companyId="company-1"
|
|
skill={skill}
|
|
canManage={false}
|
|
/>,
|
|
);
|
|
expect(document.body.textContent).toContain("Reviewer");
|
|
expect(document.body.querySelector('button[aria-label^="Remove this skill from"]')).toBeNull();
|
|
expect(document.body.querySelector("select")).toBeNull();
|
|
});
|
|
});
|