mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-19 20:44:16 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The inbox is where operators quickly scan which issues are active, blocked, or waiting for attention > - A blocked parent can still have active descendant work, but the inbox previously depended on only loaded rows to infer that state > - That made collapsed or partially loaded issue trees look more stuck than they really were > - This pull request carries live descendant summary data through the issue list API and inbox UI > - The benefit is a more accurate blocked-inbox signal, so operators can distinguish truly stalled work from blocked parents that still have live child activity ## Linked Issues or Issue Description No public GitHub issue was found for this exact inbox descendant-status polish. Feature request fields: **Subsystem affected** Cross-cutting: `server/`, `packages/shared`, plugin/MCP API surfaces, and `ui/` inbox rendering. **Problem or motivation** Inbox rows need to show when blocked or collapsed parents still have live descendant work, even when the live child row is not loaded in the current client tree. Without a server-provided descendant summary, a parent can look stalled even though active work continues below it. **Proposed solution** Expose an optional live descendant count on issue list results, request it from inbox views, and use it to render covered blocked status and live-below indicators. Keep the field opt-in so other issue list callers keep their existing payload shape and query cost. **Alternatives considered** Relying only on client-loaded subtree state was ruled out because it misses collapsed or unloaded descendants. Always returning the count was also avoided because most list callers do not need this extra summary. **Roadmap alignment** This is scoped operator-visibility polish for the existing inbox. It does not duplicate a named `ROADMAP.md` milestone. **Additional context** The recursive summary query is guarded against parent cycles, and the UI still falls back to loaded subtree live counts when server summary data is absent or stale. ## What Changed - Added optional `includeLiveDescendantSummary` support to issue list contracts, SDK surfaces, MCP tools, routes, services, and tests. - Added `liveDescendantCount` to issue list results when requested. - Updated inbox and blocked-inbox queries to request live descendant summaries. - Updated inbox row status rendering so blocked parents with live descendants show covered blocker treatment without duplicating the live-below chip. - Hardened live descendant summary traversal against parent cycles and preserved the loaded-subtree fallback path for blocked inbox rows. - Added focused tests for the API parameter, service behavior, helper logic, cycle handling, and inbox UI query/rendering behavior. ## Verification - `pnpm exec vitest run server/src/__tests__/issue-list-assignee-filter-routes.test.ts ui/src/lib/inbox-live-descendants.test.ts ui/src/components/IssueColumns.test.tsx ui/src/components/BlockedInboxView.test.tsx ui/src/pages/Inbox.test.tsx` - `pnpm --filter @paperclipai/ui typecheck` - Rebased cleanly onto current upstream `master` before pushing. - Confirmed the branch diff does not include `pnpm-lock.yaml` or `.github/workflows/*` changes. ## Risks Low to moderate risk. The new descendant count is opt-in on list requests, but it adds query work when the inbox asks for it. The recursive traversal now tracks visited ancestors to avoid cycle failures. The UI uses the server count as a supplement to existing loaded-tree state, so stale or absent counts fall back to the prior 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 coding agent, tool-enabled with local shell and git access. Reasoning mode and context window are managed by the Paperclip/Codex runtime. ## 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
635 lines
24 KiB
TypeScript
635 lines
24 KiB
TypeScript
import { z } from "zod";
|
|
import {
|
|
addIssueCommentSchema,
|
|
askUserQuestionsPayloadSchema,
|
|
checkoutIssueSchema,
|
|
createApprovalSchema,
|
|
createIssueInputSchema,
|
|
issueThreadInteractionContinuationPolicySchema,
|
|
requestCheckboxConfirmationPayloadSchema,
|
|
requestConfirmationPayloadSchema,
|
|
suggestTasksPayloadSchema,
|
|
updateIssueSchema,
|
|
upsertIssueDocumentSchema,
|
|
linkIssueApprovalSchema,
|
|
} from "@paperclipai/shared";
|
|
import { PaperclipApiClient } from "./client.js";
|
|
import { formatErrorResponse, formatTextResponse } from "./format.js";
|
|
|
|
export interface ToolDefinition {
|
|
name: string;
|
|
description: string;
|
|
schema: z.AnyZodObject;
|
|
execute: (input: Record<string, unknown>) => Promise<{
|
|
content: Array<{ type: "text"; text: string }>;
|
|
}>;
|
|
}
|
|
|
|
function makeTool<TSchema extends z.ZodRawShape>(
|
|
name: string,
|
|
description: string,
|
|
schema: z.ZodObject<TSchema>,
|
|
execute: (input: z.infer<typeof schema>) => Promise<unknown>,
|
|
): ToolDefinition {
|
|
return {
|
|
name,
|
|
description,
|
|
schema,
|
|
execute: async (input) => {
|
|
try {
|
|
const parsed = schema.parse(input);
|
|
return formatTextResponse(await execute(parsed));
|
|
} catch (error) {
|
|
return formatErrorResponse(error);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
function parseOptionalJson(raw: string | undefined | null): unknown {
|
|
if (!raw || raw.trim().length === 0) return undefined;
|
|
return JSON.parse(raw);
|
|
}
|
|
|
|
const companyIdOptional = z.string().uuid().optional().nullable();
|
|
const agentIdOptional = z.string().uuid().optional().nullable();
|
|
const issueIdSchema = z.string().min(1);
|
|
const projectIdSchema = z.string().min(1);
|
|
const goalIdSchema = z.string().uuid();
|
|
const approvalIdSchema = z.string().uuid();
|
|
const documentKeySchema = z.string().trim().min(1).max(64);
|
|
|
|
const listIssuesSchema = z.object({
|
|
companyId: companyIdOptional,
|
|
status: z.string().optional(),
|
|
projectId: z.string().uuid().optional(),
|
|
assigneeAgentId: z.string().uuid().optional(),
|
|
participantAgentId: z.string().uuid().optional(),
|
|
assigneeUserId: z.string().optional(),
|
|
touchedByUserId: z.string().optional(),
|
|
inboxArchivedByUserId: z.string().optional(),
|
|
unreadForUserId: z.string().optional(),
|
|
labelId: z.string().uuid().optional(),
|
|
executionWorkspaceId: z.string().uuid().optional(),
|
|
originKind: z.string().optional(),
|
|
originId: z.string().optional(),
|
|
includeRoutineExecutions: z.boolean().optional(),
|
|
includeLiveDescendantSummary: z.boolean().optional(),
|
|
q: z.string().optional(),
|
|
});
|
|
|
|
const listCommentsSchema = z.object({
|
|
issueId: issueIdSchema,
|
|
after: z.string().uuid().optional(),
|
|
order: z.enum(["asc", "desc"]).optional(),
|
|
limit: z.number().int().positive().max(500).optional(),
|
|
});
|
|
|
|
const upsertDocumentToolSchema = z.object({
|
|
issueId: issueIdSchema,
|
|
key: documentKeySchema,
|
|
title: z.string().trim().max(200).nullable().optional(),
|
|
format: z.enum(["markdown"]).default("markdown"),
|
|
body: z.string().max(524288),
|
|
changeSummary: z.string().trim().max(500).nullable().optional(),
|
|
baseRevisionId: z.string().uuid().nullable().optional(),
|
|
});
|
|
|
|
const createIssueToolSchema = z.object({
|
|
companyId: companyIdOptional,
|
|
}).merge(createIssueInputSchema);
|
|
|
|
const updateIssueToolSchema = z.object({
|
|
issueId: issueIdSchema,
|
|
}).merge(updateIssueSchema);
|
|
|
|
const checkoutIssueToolSchema = z.object({
|
|
issueId: issueIdSchema,
|
|
agentId: agentIdOptional,
|
|
expectedStatuses: checkoutIssueSchema.shape.expectedStatuses.optional(),
|
|
});
|
|
|
|
const addCommentToolSchema = z.object({
|
|
issueId: issueIdSchema,
|
|
}).merge(addIssueCommentSchema);
|
|
|
|
const createSuggestTasksToolSchema = z.object({
|
|
issueId: issueIdSchema,
|
|
idempotencyKey: z.string().trim().max(255).nullable().optional(),
|
|
sourceCommentId: z.string().uuid().nullable().optional(),
|
|
sourceRunId: z.string().uuid().nullable().optional(),
|
|
title: z.string().trim().max(240).nullable().optional(),
|
|
summary: z.string().trim().max(1000).nullable().optional(),
|
|
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"),
|
|
payload: suggestTasksPayloadSchema,
|
|
});
|
|
|
|
const createAskUserQuestionsToolSchema = z.object({
|
|
issueId: issueIdSchema,
|
|
idempotencyKey: z.string().trim().max(255).nullable().optional(),
|
|
sourceCommentId: z.string().uuid().nullable().optional(),
|
|
sourceRunId: z.string().uuid().nullable().optional(),
|
|
title: z.string().trim().max(240).nullable().optional(),
|
|
summary: z.string().trim().max(1000).nullable().optional(),
|
|
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"),
|
|
payload: askUserQuestionsPayloadSchema,
|
|
});
|
|
|
|
const createRequestConfirmationToolSchema = z.object({
|
|
issueId: issueIdSchema,
|
|
idempotencyKey: z.string().trim().max(255).nullable().optional(),
|
|
sourceCommentId: z.string().uuid().nullable().optional(),
|
|
sourceRunId: z.string().uuid().nullable().optional(),
|
|
title: z.string().trim().max(240).nullable().optional(),
|
|
summary: z.string().trim().max(1000).nullable().optional(),
|
|
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("none"),
|
|
payload: requestConfirmationPayloadSchema,
|
|
});
|
|
|
|
const createRequestCheckboxConfirmationToolSchema = z.object({
|
|
issueId: issueIdSchema,
|
|
idempotencyKey: z.string().trim().max(255).nullable().optional(),
|
|
sourceCommentId: z.string().uuid().nullable().optional(),
|
|
sourceRunId: z.string().uuid().nullable().optional(),
|
|
title: z.string().trim().max(240).nullable().optional(),
|
|
summary: z.string().trim().max(1000).nullable().optional(),
|
|
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"),
|
|
payload: requestCheckboxConfirmationPayloadSchema,
|
|
});
|
|
|
|
const approvalDecisionSchema = z.object({
|
|
approvalId: approvalIdSchema,
|
|
action: z.enum(["approve", "reject", "requestRevision", "resubmit"]),
|
|
decisionNote: z.string().optional(),
|
|
payloadJson: z.string().optional(),
|
|
});
|
|
|
|
const createApprovalToolSchema = z.object({
|
|
companyId: companyIdOptional,
|
|
}).merge(createApprovalSchema);
|
|
|
|
const apiRequestSchema = z.object({
|
|
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
|
path: z.string().min(1),
|
|
jsonBody: z.string().optional(),
|
|
});
|
|
|
|
const workspaceRuntimeControlTargetSchema = z.object({
|
|
workspaceCommandId: z.string().min(1).optional().nullable(),
|
|
runtimeServiceId: z.string().uuid().optional().nullable(),
|
|
serviceIndex: z.number().int().nonnegative().optional().nullable(),
|
|
});
|
|
|
|
const issueWorkspaceRuntimeControlSchema = z.object({
|
|
issueId: issueIdSchema,
|
|
action: z.enum(["start", "stop", "restart"]),
|
|
}).merge(workspaceRuntimeControlTargetSchema);
|
|
|
|
const waitForIssueWorkspaceServiceSchema = z.object({
|
|
issueId: issueIdSchema,
|
|
runtimeServiceId: z.string().uuid().optional().nullable(),
|
|
serviceName: z.string().min(1).optional().nullable(),
|
|
timeoutSeconds: z.number().int().positive().max(300).optional(),
|
|
});
|
|
|
|
function sleep(ms: number) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function readCurrentExecutionWorkspace(context: unknown): Record<string, unknown> | null {
|
|
if (!context || typeof context !== "object") return null;
|
|
const workspace = (context as { currentExecutionWorkspace?: unknown }).currentExecutionWorkspace;
|
|
return workspace && typeof workspace === "object" ? workspace as Record<string, unknown> : null;
|
|
}
|
|
|
|
function readWorkspaceRuntimeServices(workspace: Record<string, unknown> | null): Array<Record<string, unknown>> {
|
|
const raw = workspace?.runtimeServices;
|
|
return Array.isArray(raw)
|
|
? raw.filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object")
|
|
: [];
|
|
}
|
|
|
|
function selectRuntimeService(
|
|
services: Array<Record<string, unknown>>,
|
|
input: { runtimeServiceId?: string | null; serviceName?: string | null },
|
|
) {
|
|
if (input.runtimeServiceId) {
|
|
return services.find((service) => service.id === input.runtimeServiceId) ?? null;
|
|
}
|
|
if (input.serviceName) {
|
|
return services.find((service) => service.serviceName === input.serviceName) ?? null;
|
|
}
|
|
return services.find((service) => service.status === "running" || service.status === "starting")
|
|
?? services[0]
|
|
?? null;
|
|
}
|
|
|
|
async function getIssueWorkspaceRuntime(client: PaperclipApiClient, issueId: string) {
|
|
const context = await client.requestJson("GET", `/issues/${encodeURIComponent(issueId)}/heartbeat-context`);
|
|
const workspace = readCurrentExecutionWorkspace(context);
|
|
return {
|
|
context,
|
|
workspace,
|
|
runtimeServices: readWorkspaceRuntimeServices(workspace),
|
|
};
|
|
}
|
|
|
|
export function createToolDefinitions(client: PaperclipApiClient): ToolDefinition[] {
|
|
return [
|
|
makeTool(
|
|
"paperclipMe",
|
|
"Get the current authenticated Paperclip actor details",
|
|
z.object({}),
|
|
async () => client.requestJson("GET", "/agents/me"),
|
|
),
|
|
makeTool(
|
|
"paperclipInboxLite",
|
|
"Get the current authenticated agent inbox-lite assignment list",
|
|
z.object({}),
|
|
async () => client.requestJson("GET", "/agents/me/inbox-lite"),
|
|
),
|
|
makeTool(
|
|
"paperclipListAgents",
|
|
"List agents in a company",
|
|
z.object({ companyId: companyIdOptional }),
|
|
async ({ companyId }) => client.requestJson("GET", `/companies/${client.resolveCompanyId(companyId)}/agents`),
|
|
),
|
|
makeTool(
|
|
"paperclipGetAgent",
|
|
"Get a single agent by id",
|
|
z.object({ agentId: z.string().min(1), companyId: companyIdOptional }),
|
|
async ({ agentId, companyId }) => {
|
|
const qs = companyId ? `?companyId=${encodeURIComponent(companyId)}` : "";
|
|
return client.requestJson("GET", `/agents/${encodeURIComponent(agentId)}${qs}`);
|
|
},
|
|
),
|
|
makeTool(
|
|
"paperclipListIssues",
|
|
"List issues for a company with optional filters",
|
|
listIssuesSchema,
|
|
async (input) => {
|
|
const companyId = client.resolveCompanyId(input.companyId);
|
|
const params = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(input)) {
|
|
if (key === "companyId" || value === undefined || value === null) continue;
|
|
params.set(key, String(value));
|
|
}
|
|
const qs = params.toString();
|
|
return client.requestJson("GET", `/companies/${companyId}/issues${qs ? `?${qs}` : ""}`);
|
|
},
|
|
),
|
|
makeTool(
|
|
"paperclipGetIssue",
|
|
"Get a single issue by UUID or identifier",
|
|
z.object({ issueId: issueIdSchema }),
|
|
async ({ issueId }) => client.requestJson("GET", `/issues/${encodeURIComponent(issueId)}`),
|
|
),
|
|
makeTool(
|
|
"paperclipGetHeartbeatContext",
|
|
"Get compact heartbeat context for an issue",
|
|
z.object({ issueId: issueIdSchema, wakeCommentId: z.string().uuid().optional() }),
|
|
async ({ issueId, wakeCommentId }) => {
|
|
const qs = wakeCommentId ? `?wakeCommentId=${encodeURIComponent(wakeCommentId)}` : "";
|
|
return client.requestJson("GET", `/issues/${encodeURIComponent(issueId)}/heartbeat-context${qs}`);
|
|
},
|
|
),
|
|
makeTool(
|
|
"paperclipListComments",
|
|
"List issue comments with incremental options",
|
|
listCommentsSchema,
|
|
async ({ issueId, after, order, limit }) => {
|
|
const params = new URLSearchParams();
|
|
if (after) params.set("after", after);
|
|
if (order) params.set("order", order);
|
|
if (limit) params.set("limit", String(limit));
|
|
const qs = params.toString();
|
|
return client.requestJson("GET", `/issues/${encodeURIComponent(issueId)}/comments${qs ? `?${qs}` : ""}`);
|
|
},
|
|
),
|
|
makeTool(
|
|
"paperclipGetComment",
|
|
"Get a specific issue comment by id",
|
|
z.object({ issueId: issueIdSchema, commentId: z.string().uuid() }),
|
|
async ({ issueId, commentId }) =>
|
|
client.requestJson("GET", `/issues/${encodeURIComponent(issueId)}/comments/${encodeURIComponent(commentId)}`),
|
|
),
|
|
makeTool(
|
|
"paperclipListIssueApprovals",
|
|
"List approvals linked to an issue",
|
|
z.object({ issueId: issueIdSchema }),
|
|
async ({ issueId }) => client.requestJson("GET", `/issues/${encodeURIComponent(issueId)}/approvals`),
|
|
),
|
|
makeTool(
|
|
"paperclipListDocuments",
|
|
"List issue documents",
|
|
z.object({ issueId: issueIdSchema }),
|
|
async ({ issueId }) => client.requestJson("GET", `/issues/${encodeURIComponent(issueId)}/documents`),
|
|
),
|
|
makeTool(
|
|
"paperclipGetDocument",
|
|
"Get one issue document by key",
|
|
z.object({ issueId: issueIdSchema, key: documentKeySchema }),
|
|
async ({ issueId, key }) =>
|
|
client.requestJson("GET", `/issues/${encodeURIComponent(issueId)}/documents/${encodeURIComponent(key)}`),
|
|
),
|
|
makeTool(
|
|
"paperclipListDocumentRevisions",
|
|
"List revisions for an issue document",
|
|
z.object({ issueId: issueIdSchema, key: documentKeySchema }),
|
|
async ({ issueId, key }) =>
|
|
client.requestJson(
|
|
"GET",
|
|
`/issues/${encodeURIComponent(issueId)}/documents/${encodeURIComponent(key)}/revisions`,
|
|
),
|
|
),
|
|
makeTool(
|
|
"paperclipListProjects",
|
|
"List projects in a company",
|
|
z.object({ companyId: companyIdOptional }),
|
|
async ({ companyId }) => client.requestJson("GET", `/companies/${client.resolveCompanyId(companyId)}/projects`),
|
|
),
|
|
makeTool(
|
|
"paperclipGetProject",
|
|
"Get a project by id or company-scoped short reference",
|
|
z.object({ projectId: projectIdSchema, companyId: companyIdOptional }),
|
|
async ({ projectId, companyId }) => {
|
|
const qs = companyId ? `?companyId=${encodeURIComponent(companyId)}` : "";
|
|
return client.requestJson("GET", `/projects/${encodeURIComponent(projectId)}${qs}`);
|
|
},
|
|
),
|
|
makeTool(
|
|
"paperclipGetIssueWorkspaceRuntime",
|
|
"Get the current execution workspace and runtime services for an issue, including service URLs",
|
|
z.object({ issueId: issueIdSchema }),
|
|
async ({ issueId }) => getIssueWorkspaceRuntime(client, issueId),
|
|
),
|
|
makeTool(
|
|
"paperclipControlIssueWorkspaceServices",
|
|
"Start, stop, or restart the current issue execution workspace runtime services",
|
|
issueWorkspaceRuntimeControlSchema,
|
|
async ({ issueId, action, ...target }) => {
|
|
const runtime = await getIssueWorkspaceRuntime(client, issueId);
|
|
const workspaceId = typeof runtime.workspace?.id === "string" ? runtime.workspace.id : null;
|
|
if (!workspaceId) {
|
|
throw new Error("Issue has no current execution workspace");
|
|
}
|
|
return client.requestJson(
|
|
"POST",
|
|
`/execution-workspaces/${encodeURIComponent(workspaceId)}/runtime-services/${action}`,
|
|
{ body: target },
|
|
);
|
|
},
|
|
),
|
|
makeTool(
|
|
"paperclipWaitForIssueWorkspaceService",
|
|
"Wait until an issue execution workspace runtime service is running and has a URL when one is exposed",
|
|
waitForIssueWorkspaceServiceSchema,
|
|
async ({ issueId, runtimeServiceId, serviceName, timeoutSeconds }) => {
|
|
const deadline = Date.now() + (timeoutSeconds ?? 60) * 1000;
|
|
let latest: Awaited<ReturnType<typeof getIssueWorkspaceRuntime>> | null = null;
|
|
while (Date.now() <= deadline) {
|
|
latest = await getIssueWorkspaceRuntime(client, issueId);
|
|
const service = selectRuntimeService(latest.runtimeServices, { runtimeServiceId, serviceName });
|
|
if (service?.status === "running" && service.healthStatus !== "unhealthy") {
|
|
return {
|
|
workspace: latest.workspace,
|
|
service,
|
|
};
|
|
}
|
|
await sleep(1000);
|
|
}
|
|
|
|
return {
|
|
timedOut: true,
|
|
latestWorkspace: latest?.workspace ?? null,
|
|
latestRuntimeServices: latest?.runtimeServices ?? [],
|
|
};
|
|
},
|
|
),
|
|
makeTool(
|
|
"paperclipListGoals",
|
|
"List goals in a company",
|
|
z.object({ companyId: companyIdOptional }),
|
|
async ({ companyId }) => client.requestJson("GET", `/companies/${client.resolveCompanyId(companyId)}/goals`),
|
|
),
|
|
makeTool(
|
|
"paperclipGetGoal",
|
|
"Get a goal by id",
|
|
z.object({ goalId: goalIdSchema }),
|
|
async ({ goalId }) => client.requestJson("GET", `/goals/${encodeURIComponent(goalId)}`),
|
|
),
|
|
makeTool(
|
|
"paperclipListApprovals",
|
|
"List approvals in a company",
|
|
z.object({ companyId: companyIdOptional, status: z.string().optional() }),
|
|
async ({ companyId, status }) => {
|
|
const qs = status ? `?status=${encodeURIComponent(status)}` : "";
|
|
return client.requestJson("GET", `/companies/${client.resolveCompanyId(companyId)}/approvals${qs}`);
|
|
},
|
|
),
|
|
makeTool(
|
|
"paperclipCreateApproval",
|
|
"Create a board approval request, optionally linked to one or more issues",
|
|
createApprovalToolSchema,
|
|
async ({ companyId, ...body }) =>
|
|
client.requestJson("POST", `/companies/${client.resolveCompanyId(companyId)}/approvals`, {
|
|
body,
|
|
}),
|
|
),
|
|
makeTool(
|
|
"paperclipGetApproval",
|
|
"Get an approval by id",
|
|
z.object({ approvalId: approvalIdSchema }),
|
|
async ({ approvalId }) => client.requestJson("GET", `/approvals/${encodeURIComponent(approvalId)}`),
|
|
),
|
|
makeTool(
|
|
"paperclipGetApprovalIssues",
|
|
"List issues linked to an approval",
|
|
z.object({ approvalId: approvalIdSchema }),
|
|
async ({ approvalId }) => client.requestJson("GET", `/approvals/${encodeURIComponent(approvalId)}/issues`),
|
|
),
|
|
makeTool(
|
|
"paperclipListApprovalComments",
|
|
"List comments for an approval",
|
|
z.object({ approvalId: approvalIdSchema }),
|
|
async ({ approvalId }) => client.requestJson("GET", `/approvals/${encodeURIComponent(approvalId)}/comments`),
|
|
),
|
|
makeTool(
|
|
"paperclipCreateIssue",
|
|
"Create a new issue",
|
|
createIssueToolSchema,
|
|
async ({ companyId, ...body }) =>
|
|
client.requestJson("POST", `/companies/${client.resolveCompanyId(companyId)}/issues`, { body }),
|
|
),
|
|
makeTool(
|
|
"paperclipUpdateIssue",
|
|
"Patch an issue, optionally including a comment; include resume=true when intentionally requesting follow-up on resumable closed work",
|
|
updateIssueToolSchema,
|
|
async ({ issueId, ...body }) =>
|
|
client.requestJson("PATCH", `/issues/${encodeURIComponent(issueId)}`, { body }),
|
|
),
|
|
makeTool(
|
|
"paperclipCheckoutIssue",
|
|
"Checkout an issue for an agent",
|
|
checkoutIssueToolSchema,
|
|
async ({ issueId, agentId, expectedStatuses }) =>
|
|
client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/checkout`, {
|
|
body: {
|
|
agentId: client.resolveAgentId(agentId),
|
|
expectedStatuses: expectedStatuses ?? ["todo", "backlog", "blocked"],
|
|
},
|
|
}),
|
|
),
|
|
makeTool(
|
|
"paperclipReleaseIssue",
|
|
"Release an issue checkout",
|
|
z.object({ issueId: issueIdSchema }),
|
|
async ({ issueId }) => client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/release`, { body: {} }),
|
|
),
|
|
makeTool(
|
|
"paperclipAddComment",
|
|
"Add a comment to an issue; include resume=true when intentionally requesting follow-up on resumable closed work",
|
|
addCommentToolSchema,
|
|
async ({ issueId, ...body }) =>
|
|
client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/comments`, { body }),
|
|
),
|
|
makeTool(
|
|
"paperclipSuggestTasks",
|
|
"Create a suggest_tasks interaction on an issue",
|
|
createSuggestTasksToolSchema,
|
|
async ({ issueId, ...body }) =>
|
|
client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/interactions`, {
|
|
body: {
|
|
kind: "suggest_tasks",
|
|
...body,
|
|
},
|
|
}),
|
|
),
|
|
makeTool(
|
|
"paperclipAskUserQuestions",
|
|
"Create an ask_user_questions interaction on an issue",
|
|
createAskUserQuestionsToolSchema,
|
|
async ({ issueId, ...body }) =>
|
|
client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/interactions`, {
|
|
body: {
|
|
kind: "ask_user_questions",
|
|
...body,
|
|
},
|
|
}),
|
|
),
|
|
makeTool(
|
|
"paperclipRequestConfirmation",
|
|
"Create a request_confirmation interaction on an issue",
|
|
createRequestConfirmationToolSchema,
|
|
async ({ issueId, ...body }) =>
|
|
client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/interactions`, {
|
|
body: {
|
|
kind: "request_confirmation",
|
|
...body,
|
|
},
|
|
}),
|
|
),
|
|
makeTool(
|
|
"paperclipRequestCheckboxConfirmation",
|
|
"Create a request_checkbox_confirmation interaction on an issue",
|
|
createRequestCheckboxConfirmationToolSchema,
|
|
async ({ issueId, ...body }) =>
|
|
client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/interactions`, {
|
|
body: {
|
|
kind: "request_checkbox_confirmation",
|
|
...body,
|
|
},
|
|
}),
|
|
),
|
|
makeTool(
|
|
"paperclipUpsertIssueDocument",
|
|
"Create or update an issue document",
|
|
upsertDocumentToolSchema,
|
|
async ({ issueId, key, ...body }) =>
|
|
client.requestJson(
|
|
"PUT",
|
|
`/issues/${encodeURIComponent(issueId)}/documents/${encodeURIComponent(key)}`,
|
|
{ body },
|
|
),
|
|
),
|
|
makeTool(
|
|
"paperclipRestoreIssueDocumentRevision",
|
|
"Restore a prior revision of an issue document",
|
|
z.object({
|
|
issueId: issueIdSchema,
|
|
key: documentKeySchema,
|
|
revisionId: z.string().uuid(),
|
|
}),
|
|
async ({ issueId, key, revisionId }) =>
|
|
client.requestJson(
|
|
"POST",
|
|
`/issues/${encodeURIComponent(issueId)}/documents/${encodeURIComponent(key)}/revisions/${encodeURIComponent(revisionId)}/restore`,
|
|
{ body: {} },
|
|
),
|
|
),
|
|
makeTool(
|
|
"paperclipLinkIssueApproval",
|
|
"Link an approval to an issue",
|
|
z.object({ issueId: issueIdSchema }).merge(linkIssueApprovalSchema),
|
|
async ({ issueId, approvalId }) =>
|
|
client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/approvals`, {
|
|
body: { approvalId },
|
|
}),
|
|
),
|
|
makeTool(
|
|
"paperclipUnlinkIssueApproval",
|
|
"Unlink an approval from an issue",
|
|
z.object({ issueId: issueIdSchema, approvalId: approvalIdSchema }),
|
|
async ({ issueId, approvalId }) =>
|
|
client.requestJson(
|
|
"DELETE",
|
|
`/issues/${encodeURIComponent(issueId)}/approvals/${encodeURIComponent(approvalId)}`,
|
|
),
|
|
),
|
|
makeTool(
|
|
"paperclipApprovalDecision",
|
|
"Approve, reject, request revision, or resubmit an approval",
|
|
approvalDecisionSchema,
|
|
async ({ approvalId, action, decisionNote, payloadJson }) => {
|
|
const path =
|
|
action === "approve"
|
|
? `/approvals/${encodeURIComponent(approvalId)}/approve`
|
|
: action === "reject"
|
|
? `/approvals/${encodeURIComponent(approvalId)}/reject`
|
|
: action === "requestRevision"
|
|
? `/approvals/${encodeURIComponent(approvalId)}/request-revision`
|
|
: `/approvals/${encodeURIComponent(approvalId)}/resubmit`;
|
|
|
|
const body =
|
|
action === "resubmit"
|
|
? { payload: parseOptionalJson(payloadJson) ?? {} }
|
|
: { decisionNote };
|
|
|
|
return client.requestJson("POST", path, { body });
|
|
},
|
|
),
|
|
makeTool(
|
|
"paperclipAddApprovalComment",
|
|
"Add a comment to an approval",
|
|
z.object({ approvalId: approvalIdSchema, body: z.string().min(1) }),
|
|
async ({ approvalId, body }) =>
|
|
client.requestJson("POST", `/approvals/${encodeURIComponent(approvalId)}/comments`, {
|
|
body: { body },
|
|
}),
|
|
),
|
|
makeTool(
|
|
"paperclipApiRequest",
|
|
"Make a JSON request to an existing Paperclip /api endpoint for unsupported operations",
|
|
apiRequestSchema,
|
|
async ({ method, path, jsonBody }) => {
|
|
if (!path.startsWith("/") || path.includes("..")) {
|
|
throw new Error("path must start with / and be relative to /api, and must not contain '..'");
|
|
}
|
|
return client.requestJson(method, path, {
|
|
body: parseOptionalJson(jsonBody),
|
|
});
|
|
},
|
|
),
|
|
];
|
|
}
|