mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-09 06:15:21 +02:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - An agent's runtime mounts only its own enabled skills; nothing tells
the model what else the company skill library holds
> - From inside a sandbox, "installed but not enabled for me" and "does
not exist" look identical, so agents tell users freshly installed skills
are not installed
> - This pull request renders the library as a deterministic markdown
section appended to claude-local agent instructions, and adds a
paperclipListSkills MCP tool
> - The benefit is that agents report the true state ("installed, not
enabled for me — ask an operator to enable it") instead of a false
negative
## Linked Issues or Issue Description
**What existing behavior does this improve?**
How agents reason about the company skill library at runtime.
**Subsystem affected**
`packages/adapter-utils` (new pure builder),
`packages/adapters/claude-local` (instructions append),
`packages/mcp-server` (new tool).
**Current behavior**
The runtime hands adapters the full library list, but only the agent's
enabled skills are mounted, and no prompt content or MCP tool describes
the rest. Agents inspect their sandbox, find nothing, and report
installed skills as not installed.
**Proposed behavior**
A "Company skill library" markdown section lists every skill as
`enabled`, `installed, not enabled for you`, or `enabled but
unavailable: <cause>`, with instructions to report the not-enabled state
accurately and ask an operator to enable it. claude-local appends it to
the agent instructions text. A `paperclipListSkills` MCP tool exposes
the same list on demand.
**Breaking changes**
None. Other adapters are untouched (they can adopt the builder later);
the manifest is deterministic, so the claude-local prompt-bundle cache
only busts when the library actually changes.
## What Changed
- New `packages/adapter-utils/src/skill-library-manifest.ts` with
`buildSkillLibraryManifestMarkdown` (pure, key-sorted, deterministic;
renders the missing-cause detail from #12146).
- `packages/adapters/claude-local/src/server/execute.ts` appends the
manifest to `combinedInstructionsContents` (creating it when no
instructions file is configured).
- `packages/mcp-server/src/tools.ts` adds `paperclipListSkills` hitting
`GET /companies/:companyId/skills`.
## Verification
- `npx vitest run
packages/adapter-utils/src/skill-library-manifest.test.ts` (from repo
root) — 3 tests: byte-identical output for shuffled input, state
rendering incl. the unavailable cause, change detection.
- `cd packages/mcp-server && npx vitest run` — new tool routing test
passes (13 passed; 1 pre-existing failure on my machine reproduces
unchanged at the branch base).
- `cd packages/adapters/claude-local && npx vitest run` — 244 passed, 1
skipped.
- `pnpm run typecheck` clean in adapter-utils, mcp-server, claude-local.
## Risks
- Prompt growth is one line per installed skill plus a five-line header
— bounded and only present when the library is non-empty. Stacked on
#12146 so the manifest's "enabled but unavailable" state reflects real
materialization failures.
## Model Used
- Claude Fable 5 (`claude-fable-5`, Anthropic) with extended thinking
and tool use, via Claude Code.
## 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
- [ ] 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
418 lines
14 KiB
TypeScript
418 lines
14 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { PaperclipApiClient } from "./client.js";
|
|
import { createToolDefinitions } from "./tools.js";
|
|
|
|
function makeClient() {
|
|
return new PaperclipApiClient({
|
|
apiUrl: "http://localhost:3100/api",
|
|
apiKey: "token-123",
|
|
companyId: "11111111-1111-1111-1111-111111111111",
|
|
agentId: "22222222-2222-2222-2222-222222222222",
|
|
runId: "33333333-3333-3333-3333-333333333333",
|
|
});
|
|
}
|
|
|
|
function getTool(name: string) {
|
|
const tool = createToolDefinitions(makeClient()).find((candidate) => candidate.name === name);
|
|
if (!tool) throw new Error(`Missing tool ${name}`);
|
|
return tool;
|
|
}
|
|
|
|
function mockJsonResponse(body: unknown, status = 200) {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
describe("paperclip MCP tools", () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("adds auth headers and run id to mutating requests", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
mockJsonResponse({ ok: true }),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const tool = getTool("paperclipUpdateIssue");
|
|
await tool.execute({
|
|
issueId: "PAP-1135",
|
|
status: "done",
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(String(url)).toBe("http://localhost:3100/api/issues/PAP-1135");
|
|
expect(init.method).toBe("PATCH");
|
|
expect((init.headers as Record<string, string>)["Authorization"]).toBe("Bearer token-123");
|
|
expect((init.headers as Record<string, string>)["X-Paperclip-Run-Id"]).toBe(
|
|
"33333333-3333-3333-3333-333333333333",
|
|
);
|
|
});
|
|
|
|
it("lists the company skill library with the default company id", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
mockJsonResponse([{ key: "paperclipai/bundled/product/wireframe", name: "wireframe" }]),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const tool = getTool("paperclipListSkills");
|
|
const response = await tool.execute({});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [url] = fetchMock.mock.calls[0] as [string];
|
|
expect(String(url)).toBe(
|
|
"http://localhost:3100/api/companies/11111111-1111-1111-1111-111111111111/skills",
|
|
);
|
|
expect(response.content[0]?.text).toContain("wireframe");
|
|
});
|
|
|
|
it("uses default company id for company-scoped list tools", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
mockJsonResponse([{ id: "issue-1" }]),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const tool = getTool("paperclipListIssues");
|
|
const response = await tool.execute({});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [url] = fetchMock.mock.calls[0] as [string];
|
|
expect(String(url)).toBe(
|
|
"http://localhost:3100/api/companies/11111111-1111-1111-1111-111111111111/issues",
|
|
);
|
|
expect(response.content[0]?.text).toContain("issue-1");
|
|
});
|
|
|
|
it("uses default agent id for checkout requests", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
mockJsonResponse({ id: "PAP-1135", status: "in_progress" }),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const tool = getTool("paperclipCheckoutIssue");
|
|
await tool.execute({
|
|
issueId: "PAP-1135",
|
|
});
|
|
|
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(JSON.parse(String(init.body))).toEqual({
|
|
agentId: "22222222-2222-2222-2222-222222222222",
|
|
expectedStatuses: ["todo", "backlog", "blocked"],
|
|
});
|
|
});
|
|
|
|
it("allows create issue requests to omit status so the API applies assignee defaults", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
mockJsonResponse({ id: "issue-1", status: "todo" }),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const tool = getTool("paperclipCreateIssue");
|
|
await tool.execute({
|
|
title: "Assigned follow-up",
|
|
assigneeAgentId: "22222222-2222-2222-2222-222222222222",
|
|
});
|
|
|
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(String(url)).toBe(
|
|
"http://localhost:3100/api/companies/11111111-1111-1111-1111-111111111111/issues",
|
|
);
|
|
expect(init.method).toBe("POST");
|
|
expect(JSON.parse(String(init.body))).toEqual({
|
|
title: "Assigned follow-up",
|
|
workMode: "standard",
|
|
priority: "medium",
|
|
assigneeAgentId: "22222222-2222-2222-2222-222222222222",
|
|
requestDepth: 0,
|
|
});
|
|
});
|
|
|
|
it("defaults issue document format to markdown", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
mockJsonResponse({ key: "plan", latestRevisionNumber: 2 }),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const tool = getTool("paperclipUpsertIssueDocument");
|
|
await tool.execute({
|
|
issueId: "PAP-1135",
|
|
key: "plan",
|
|
body: "# Updated",
|
|
});
|
|
|
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(JSON.parse(String(init.body))).toEqual({
|
|
format: "markdown",
|
|
body: "# Updated",
|
|
});
|
|
});
|
|
|
|
it("controls issue workspace services through the current execution workspace", async () => {
|
|
const fetchMock = vi.fn()
|
|
.mockResolvedValueOnce(mockJsonResponse({
|
|
currentExecutionWorkspace: {
|
|
id: "44444444-4444-4444-8444-444444444444",
|
|
runtimeServices: [],
|
|
},
|
|
}))
|
|
.mockResolvedValueOnce(mockJsonResponse({
|
|
operation: { id: "operation-1" },
|
|
workspace: {
|
|
id: "44444444-4444-4444-8444-444444444444",
|
|
runtimeServices: [
|
|
{
|
|
id: "55555555-5555-4555-8555-555555555555",
|
|
serviceName: "web",
|
|
status: "running",
|
|
url: "http://127.0.0.1:5173",
|
|
},
|
|
],
|
|
},
|
|
}));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const tool = getTool("paperclipControlIssueWorkspaceServices");
|
|
await tool.execute({
|
|
issueId: "PAP-1135",
|
|
action: "restart",
|
|
workspaceCommandId: "web",
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
const [lookupUrl, lookupInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(String(lookupUrl)).toBe("http://localhost:3100/api/issues/PAP-1135/heartbeat-context");
|
|
expect(lookupInit.method).toBe("GET");
|
|
|
|
const [controlUrl, controlInit] = fetchMock.mock.calls[1] as [string, RequestInit];
|
|
expect(String(controlUrl)).toBe(
|
|
"http://localhost:3100/api/execution-workspaces/44444444-4444-4444-8444-444444444444/runtime-services/restart",
|
|
);
|
|
expect(controlInit.method).toBe("POST");
|
|
expect(JSON.parse(String(controlInit.body))).toEqual({
|
|
workspaceCommandId: "web",
|
|
});
|
|
});
|
|
|
|
it("waits for an issue workspace runtime service URL", async () => {
|
|
const fetchMock = vi.fn()
|
|
.mockResolvedValueOnce(mockJsonResponse({
|
|
currentExecutionWorkspace: {
|
|
id: "44444444-4444-4444-8444-444444444444",
|
|
runtimeServices: [
|
|
{
|
|
id: "55555555-5555-4555-8555-555555555555",
|
|
serviceName: "web",
|
|
status: "running",
|
|
healthStatus: "healthy",
|
|
url: "http://127.0.0.1:5173",
|
|
},
|
|
],
|
|
},
|
|
}));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const tool = getTool("paperclipWaitForIssueWorkspaceService");
|
|
const response = await tool.execute({
|
|
issueId: "PAP-1135",
|
|
serviceName: "web",
|
|
timeoutSeconds: 1,
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(response.content[0]?.text).toContain("http://127.0.0.1:5173");
|
|
});
|
|
|
|
it("creates suggest_tasks interactions with the expected issue-scoped payload", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
mockJsonResponse({ id: "interaction-1", kind: "suggest_tasks" }),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const tool = getTool("paperclipSuggestTasks");
|
|
await tool.execute({
|
|
issueId: "PAP-1135",
|
|
idempotencyKey: "run-1:suggest",
|
|
payload: {
|
|
version: 1,
|
|
tasks: [{ clientKey: "task-1", title: "One" }],
|
|
},
|
|
});
|
|
|
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(String(url)).toBe("http://localhost:3100/api/issues/PAP-1135/interactions");
|
|
expect(init.method).toBe("POST");
|
|
expect(JSON.parse(String(init.body))).toEqual({
|
|
kind: "suggest_tasks",
|
|
continuationPolicy: "wake_assignee",
|
|
idempotencyKey: "run-1:suggest",
|
|
payload: {
|
|
version: 1,
|
|
tasks: [{ clientKey: "task-1", title: "One" }],
|
|
},
|
|
});
|
|
});
|
|
|
|
it("creates request_confirmation interactions with plan target payloads", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
mockJsonResponse({ id: "interaction-1", kind: "request_confirmation" }),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const tool = getTool("paperclipRequestConfirmation");
|
|
await tool.execute({
|
|
issueId: "PAP-1135",
|
|
idempotencyKey: "confirmation:PAP-1135:plan:33333333-3333-4333-8333-333333333333",
|
|
title: "Plan approval",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Accept this plan?",
|
|
acceptLabel: "Accept plan",
|
|
allowDeclineReason: true,
|
|
rejectLabel: "Request changes",
|
|
rejectRequiresReason: true,
|
|
supersedeOnUserComment: true,
|
|
target: {
|
|
type: "issue_document",
|
|
key: "plan",
|
|
revisionId: "33333333-3333-4333-8333-333333333333",
|
|
revisionNumber: 3,
|
|
},
|
|
},
|
|
});
|
|
|
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(String(url)).toBe("http://localhost:3100/api/issues/PAP-1135/interactions");
|
|
expect(init.method).toBe("POST");
|
|
expect(JSON.parse(String(init.body))).toEqual({
|
|
kind: "request_confirmation",
|
|
continuationPolicy: "none",
|
|
idempotencyKey: "confirmation:PAP-1135:plan:33333333-3333-4333-8333-333333333333",
|
|
title: "Plan approval",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Accept this plan?",
|
|
acceptLabel: "Accept plan",
|
|
allowDeclineReason: true,
|
|
rejectLabel: "Request changes",
|
|
rejectRequiresReason: true,
|
|
supersedeOnUserComment: true,
|
|
target: {
|
|
type: "issue_document",
|
|
key: "plan",
|
|
revisionId: "33333333-3333-4333-8333-333333333333",
|
|
revisionNumber: 3,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("creates request_checkbox_confirmation interactions with checkbox payloads", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
mockJsonResponse({ id: "interaction-1", kind: "request_checkbox_confirmation" }),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const tool = getTool("paperclipRequestCheckboxConfirmation");
|
|
await tool.execute({
|
|
issueId: "PAP-1135",
|
|
idempotencyKey: "confirmation:PAP-1135:files",
|
|
title: "Choose files",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Which files should be included?",
|
|
detailsMarkdown: "Pick the files to attach.",
|
|
options: [
|
|
{ id: "file-a", label: "File A", description: "Primary draft" },
|
|
{ id: "file-b", label: "File B" },
|
|
],
|
|
defaultSelectedOptionIds: ["file-a"],
|
|
minSelected: 1,
|
|
maxSelected: 2,
|
|
acceptLabel: "Use selected files",
|
|
rejectLabel: "Do not attach files",
|
|
rejectRequiresReason: true,
|
|
allowDeclineReason: false,
|
|
},
|
|
});
|
|
|
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(String(url)).toBe("http://localhost:3100/api/issues/PAP-1135/interactions");
|
|
expect(init.method).toBe("POST");
|
|
expect(JSON.parse(String(init.body))).toEqual({
|
|
kind: "request_checkbox_confirmation",
|
|
continuationPolicy: "wake_assignee",
|
|
idempotencyKey: "confirmation:PAP-1135:files",
|
|
title: "Choose files",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Which files should be included?",
|
|
detailsMarkdown: "Pick the files to attach.",
|
|
options: [
|
|
{ id: "file-a", label: "File A", description: "Primary draft" },
|
|
{ id: "file-b", label: "File B" },
|
|
],
|
|
defaultSelectedOptionIds: ["file-a"],
|
|
minSelected: 1,
|
|
maxSelected: 2,
|
|
acceptLabel: "Use selected files",
|
|
rejectLabel: "Do not attach files",
|
|
rejectRequiresReason: true,
|
|
allowDeclineReason: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("creates approvals with the expected company-scoped payload", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
mockJsonResponse({ id: "approval-1" }),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const tool = getTool("paperclipCreateApproval");
|
|
await tool.execute({
|
|
type: "hire_agent",
|
|
payload: { branch: "pap-1167" },
|
|
issueIds: ["44444444-4444-4444-4444-444444444444"],
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(String(url)).toBe(
|
|
"http://localhost:3100/api/companies/11111111-1111-1111-1111-111111111111/approvals",
|
|
);
|
|
expect(init.method).toBe("POST");
|
|
expect(JSON.parse(String(init.body))).toEqual({
|
|
type: "hire_agent",
|
|
payload: { branch: "pap-1167" },
|
|
issueIds: ["44444444-4444-4444-4444-444444444444"],
|
|
});
|
|
});
|
|
|
|
it("rejects invalid generic request paths", async () => {
|
|
vi.stubGlobal("fetch", vi.fn());
|
|
|
|
const tool = getTool("paperclipApiRequest");
|
|
const response = await tool.execute({
|
|
method: "GET",
|
|
path: "issues",
|
|
});
|
|
|
|
expect(response.content[0]?.text).toContain("path must start with /");
|
|
});
|
|
|
|
it("rejects generic request paths that escape /api", async () => {
|
|
vi.stubGlobal("fetch", vi.fn());
|
|
|
|
const tool = getTool("paperclipApiRequest");
|
|
const response = await tool.execute({
|
|
method: "GET",
|
|
path: "/../../secret",
|
|
});
|
|
|
|
expect(response.content[0]?.text).toContain("must not contain '..'");
|
|
});
|
|
});
|