paperclip/server/src/__tests__/agent-skills-routes.test.ts
Dotta a957394420
[codex] Add structured issue-thread interactions (#4244)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Operators supervise that work through issues, comments, approvals,
and the board UI.
> - Some agent proposals need structured board/user decisions, not
hidden markdown conventions or heavyweight governed approvals.
> - Issue-thread interactions already provide a natural thread-native
surface for proposed tasks and questions.
> - This pull request extends that surface with request confirmations,
richer interaction cards, and agent/plugin/MCP helpers.
> - The benefit is that plan approvals and yes/no decisions become
explicit, auditable, and resumable without losing the single-issue
workflow.

## What Changed

- Added persisted issue-thread interactions for suggested tasks,
structured questions, and request confirmations.
- Added board UI cards for interaction review, selection, question
answers, and accept/reject confirmation flows.
- Added MCP and plugin SDK helpers for creating interaction cards from
agents/plugins.
- Updated agent wake instructions, onboarding assets, Paperclip skill
docs, and public docs to prefer structured confirmations for
issue-scoped decisions.
- Rebased the branch onto `public-gh/master` and renumbered branch
migrations to `0063` and `0064`; the idempotency migration uses `ADD
COLUMN IF NOT EXISTS` for old branch users.

## Verification

- `git diff --check public-gh/master..HEAD`
- `pnpm exec vitest run packages/adapter-utils/src/server-utils.test.ts
packages/mcp-server/src/tools.test.ts
packages/shared/src/issue-thread-interactions.test.ts
ui/src/lib/issue-thread-interactions.test.ts
ui/src/lib/issue-chat-messages.test.ts
ui/src/components/IssueThreadInteractionCard.test.tsx
ui/src/components/IssueChatThread.test.tsx
server/src/__tests__/issue-thread-interaction-routes.test.ts
server/src/__tests__/issue-thread-interactions-service.test.ts
server/src/services/issue-thread-interactions.test.ts` -> 9 files / 79
tests passed
- `pnpm -r typecheck` -> passed, including `packages/db` migration
numbering check

## Risks

- Medium: this adds a new issue-thread interaction model across
db/shared/server/ui/plugin surfaces.
- Migration risk is reduced by placing this branch after current master
migrations (`0063`, `0064`) and making the idempotency column add
idempotent for users who applied the old branch numbering.
- UI interaction behavior is covered by component tests, but this PR
does not include browser screenshots.

> 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-class coding agent runtime. Exact model ID and
context window are not exposed in this Paperclip run; tool use and local
shell/code execution were enabled.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-21 20:15:11 -05:00

539 lines
18 KiB
TypeScript

import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
update: vi.fn(),
create: vi.fn(),
resolveByReference: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
getMembership: vi.fn(),
listPrincipalGrants: vi.fn(),
ensureMembership: vi.fn(),
setPrincipalPermission: vi.fn(),
}));
const mockApprovalService = vi.hoisted(() => ({
create: vi.fn(),
}));
const mockBudgetService = vi.hoisted(() => ({}));
const mockHeartbeatService = vi.hoisted(() => ({}));
const mockIssueApprovalService = vi.hoisted(() => ({
linkManyForApproval: vi.fn(),
}));
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
const mockAgentInstructionsService = vi.hoisted(() => ({
getBundle: vi.fn(),
readFile: vi.fn(),
updateBundle: vi.fn(),
writeFile: vi.fn(),
deleteFile: vi.fn(),
exportFiles: vi.fn(),
ensureManagedBundle: vi.fn(),
materializeManagedBundle: vi.fn(),
}));
const mockCompanySkillService = vi.hoisted(() => ({
listRuntimeSkillEntries: vi.fn(),
resolveRequestedSkillKeys: vi.fn(),
}));
const mockSecretService = vi.hoisted(() => ({
resolveAdapterConfigForRuntime: vi.fn(),
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockTrackAgentCreated = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn());
const mockAdapter = vi.hoisted(() => ({
listSkills: vi.fn(),
syncSkills: vi.fn(),
}));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentCreated: mockTrackAgentCreated,
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => mockAgentInstructionsService,
accessService: () => mockAccessService,
approvalService: () => mockApprovalService,
companySkillService: () => mockCompanySkillService,
budgetService: () => mockBudgetService,
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => mockIssueApprovalService,
issueService: () => ({}),
logActivity: mockLogActivity,
secretService: () => mockSecretService,
syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
vi.mock("../adapters/index.js", () => ({
findServerAdapter: vi.fn(() => mockAdapter),
findActiveServerAdapter: vi.fn(() => mockAdapter),
listAdapterModels: vi.fn(),
detectAdapterModel: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentCreated: mockTrackAgentCreated,
trackErrorHandlerCrash: vi.fn(),
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.doMock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => mockAgentInstructionsService,
accessService: () => mockAccessService,
approvalService: () => mockApprovalService,
companySkillService: () => mockCompanySkillService,
budgetService: () => mockBudgetService,
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => mockIssueApprovalService,
issueService: () => ({}),
logActivity: mockLogActivity,
secretService: () => mockSecretService,
syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
vi.doMock("../adapters/index.js", () => ({
findServerAdapter: vi.fn(() => mockAdapter),
findActiveServerAdapter: vi.fn(() => mockAdapter),
listAdapterModels: vi.fn(),
detectAdapterModel: vi.fn(),
}));
}
function createDb(requireBoardApprovalForNewAgents = false) {
return {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(async () => [
{
id: "company-1",
requireBoardApprovalForNewAgents,
},
]),
})),
})),
};
}
async function createApp(db: Record<string, unknown> = createDb()) {
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
next();
});
app.use("/api", agentRoutes(db as any));
app.use(errorHandler);
return app;
}
function makeAgent(adapterType: string) {
return {
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
name: "Agent",
role: "engineer",
title: "Engineer",
status: "active",
reportsTo: null,
capabilities: null,
adapterType,
adapterConfig: {},
runtimeConfig: {},
permissions: null,
updatedAt: new Date(),
};
}
describe("agent skill routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/agents.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockAgentService.resolveByReference.mockResolvedValue({
ambiguous: false,
agent: makeAgent("claude_local"),
});
mockSecretService.resolveAdapterConfigForRuntime.mockResolvedValue({ config: { env: {} } });
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([
{
key: "paperclipai/paperclip/paperclip",
runtimeName: "paperclip",
source: "/tmp/paperclip",
required: true,
requiredReason: "required",
},
]);
mockCompanySkillService.resolveRequestedSkillKeys.mockImplementation(
async (_companyId: string, requested: string[]) =>
requested.map((value) =>
value === "paperclip"
? "paperclipai/paperclip/paperclip"
: value,
),
);
mockAdapter.listSkills.mockResolvedValue({
adapterType: "claude_local",
supported: true,
mode: "ephemeral",
desiredSkills: ["paperclipai/paperclip/paperclip"],
entries: [],
warnings: [],
});
mockAdapter.syncSkills.mockResolvedValue({
adapterType: "claude_local",
supported: true,
mode: "ephemeral",
desiredSkills: ["paperclipai/paperclip/paperclip"],
entries: [],
warnings: [],
});
mockAgentService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeAgent("claude_local"),
adapterConfig: patch.adapterConfig ?? {},
}));
mockAgentService.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
...makeAgent(String(input.adapterType ?? "claude_local")),
...input,
adapterConfig: input.adapterConfig ?? {},
runtimeConfig: input.runtimeConfig ?? {},
budgetMonthlyCents: Number(input.budgetMonthlyCents ?? 0),
permissions: null,
}));
mockApprovalService.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
id: "approval-1",
companyId: "company-1",
type: "hire_agent",
status: "pending",
payload: input.payload ?? {},
}));
mockAgentInstructionsService.materializeManagedBundle.mockImplementation(
async (agent: Record<string, unknown>, files: Record<string, string>) => ({
bundle: null,
adapterConfig: {
...((agent.adapterConfig as Record<string, unknown> | undefined) ?? {}),
instructionsBundleMode: "managed",
instructionsRootPath: `/tmp/${String(agent.id)}/instructions`,
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: `/tmp/${String(agent.id)}/instructions/AGENTS.md`,
promptTemplate: files["AGENTS.md"] ?? "",
},
}),
);
mockLogActivity.mockResolvedValue(undefined);
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.hasPermission.mockResolvedValue(true);
mockAccessService.getMembership.mockResolvedValue(null);
mockAccessService.listPrincipalGrants.mockResolvedValue([]);
mockAccessService.ensureMembership.mockResolvedValue(undefined);
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
});
it("skips runtime materialization when listing Claude skills", async () => {
mockAgentService.getById.mockResolvedValue(makeAgent("claude_local"));
const res = await request(await createApp())
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1");
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAdapter.listSkills).toHaveBeenCalledWith(
expect.objectContaining({
adapterType: "claude_local",
config: expect.objectContaining({
paperclipRuntimeSkills: expect.any(Array),
}),
}),
);
}, 10_000);
it("skips runtime materialization when listing Codex skills", async () => {
mockAgentService.getById.mockResolvedValue(makeAgent("codex_local"));
mockAdapter.listSkills.mockResolvedValue({
adapterType: "codex_local",
supported: true,
mode: "ephemeral",
desiredSkills: ["paperclipai/paperclip/paperclip"],
entries: [],
warnings: [],
});
const res = await request(await createApp())
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1");
expect(res.status, JSON.stringify(res.body)).toBe(200);
});
it("keeps runtime materialization for persistent skill adapters", async () => {
mockAgentService.getById.mockResolvedValue(makeAgent("cursor"));
mockAdapter.listSkills.mockResolvedValue({
adapterType: "cursor",
supported: true,
mode: "persistent",
desiredSkills: ["paperclipai/paperclip/paperclip"],
entries: [],
warnings: [],
});
const res = await request(await createApp())
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1");
expect(res.status, JSON.stringify(res.body)).toBe(200);
});
it("skips runtime materialization when syncing Claude skills", async () => {
mockAgentService.getById.mockResolvedValue(makeAgent("claude_local"));
const res = await request(await createApp())
.post("/api/agents/11111111-1111-4111-8111-111111111111/skills/sync?companyId=company-1")
.send({ desiredSkills: ["paperclipai/paperclip/paperclip"] });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAdapter.syncSkills).toHaveBeenCalled();
});
it("canonicalizes desired skill references before syncing", async () => {
mockAgentService.getById.mockResolvedValue(makeAgent("claude_local"));
const res = await request(await createApp())
.post("/api/agents/11111111-1111-4111-8111-111111111111/skills/sync?companyId=company-1")
.send({ desiredSkills: ["paperclip"] });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentService.update).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
adapterConfig: expect.objectContaining({
paperclipSkillSync: expect.objectContaining({
desiredSkills: ["paperclipai/paperclip/paperclip"],
}),
}),
}),
expect.any(Object),
);
});
it("persists canonical desired skills when creating an agent directly", async () => {
const res = await request(await createApp())
.post("/api/companies/company-1/agents")
.send({
name: "QA Agent",
role: "engineer",
adapterType: "claude_local",
desiredSkills: ["paperclip"],
adapterConfig: {},
});
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(mockAgentService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
adapterConfig: expect.objectContaining({
paperclipSkillSync: expect.objectContaining({
desiredSkills: ["paperclipai/paperclip/paperclip"],
}),
}),
}),
);
expect(mockTrackAgentCreated).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
agentId: "11111111-1111-4111-8111-111111111111",
agentRole: "engineer",
}),
);
});
it("materializes a managed AGENTS.md for directly created local agents", async () => {
const res = await request(await createApp())
.post("/api/companies/company-1/agents")
.send({
name: "QA Agent",
role: "engineer",
adapterType: "claude_local",
adapterConfig: {
promptTemplate: "You are QA.",
},
});
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(mockAgentService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
adapterConfig: expect.objectContaining({
instructionsBundleMode: "managed",
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: "/tmp/11111111-1111-4111-8111-111111111111/instructions/AGENTS.md",
}),
}),
);
expect(mockAgentService.update.mock.calls.at(-1)?.[1]).not.toMatchObject({
adapterConfig: expect.objectContaining({
promptTemplate: expect.anything(),
}),
});
});
it("materializes the bundled CEO instruction set for default CEO agents", async () => {
const res = await request(await createApp())
.post("/api/companies/company-1/agents")
.send({
name: "CEO",
role: "ceo",
adapterType: "claude_local",
adapterConfig: {},
});
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.objectContaining({
id: "11111111-1111-4111-8111-111111111111",
role: "ceo",
adapterType: "claude_local",
}),
expect.objectContaining({
"AGENTS.md": expect.stringContaining("You are the CEO."),
"HEARTBEAT.md": expect.stringContaining("CEO Heartbeat Checklist"),
"SOUL.md": expect.stringContaining("CEO Persona"),
"TOOLS.md": expect.stringContaining("# Tools"),
}),
{ entryFile: "AGENTS.md", replaceExisting: false },
);
});
it("materializes the bundled default instruction set for non-CEO agents with no prompt template", async () => {
const res = await request(await createApp())
.post("/api/companies/company-1/agents")
.send({
name: "Engineer",
role: "engineer",
adapterType: "claude_local",
adapterConfig: {},
});
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
await vi.waitFor(() => {
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.objectContaining({
id: "11111111-1111-4111-8111-111111111111",
role: "engineer",
adapterType: "claude_local",
}),
expect.objectContaining({
"AGENTS.md": expect.stringMatching(/Start actionable work in the same heartbeat\.[\s\S]*Keep the work moving until it is done\./),
}),
{ entryFile: "AGENTS.md", replaceExisting: false },
);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
"AGENTS.md": expect.stringContaining('kind: "request_confirmation"'),
}),
expect.any(Object),
);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
"AGENTS.md": expect.stringContaining("confirmation:{issueId}:plan:{revisionId}"),
}),
expect.any(Object),
);
});
});
it("includes canonical desired skills in hire approvals", async () => {
const db = createDb(true);
const res = await request(await createApp(db))
.post("/api/companies/company-1/agent-hires")
.send({
name: "QA Agent",
role: "engineer",
adapterType: "claude_local",
desiredSkills: ["paperclip"],
adapterConfig: {},
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockApprovalService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
payload: expect.objectContaining({
desiredSkills: ["paperclipai/paperclip/paperclip"],
requestedConfigurationSnapshot: expect.objectContaining({
desiredSkills: ["paperclipai/paperclip/paperclip"],
}),
}),
}),
);
});
it("uses managed AGENTS config in hire approval payloads", async () => {
const res = await request(await createApp(createDb(true)))
.post("/api/companies/company-1/agent-hires")
.send({
name: "QA Agent",
role: "engineer",
adapterType: "claude_local",
adapterConfig: {
promptTemplate: "You are QA.",
},
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockApprovalService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
payload: expect.objectContaining({
adapterConfig: expect.objectContaining({
instructionsBundleMode: "managed",
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: "/tmp/11111111-1111-4111-8111-111111111111/instructions/AGENTS.md",
}),
}),
}),
);
const approvalInput = mockApprovalService.create.mock.calls.at(-1)?.[1] as
| { payload?: { adapterConfig?: Record<string, unknown> } }
| undefined;
expect(approvalInput?.payload?.adapterConfig?.promptTemplate).toBeUndefined();
});
});