mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
543 lines
16 KiB
TypeScript
543 lines
16 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { describe, expect, it } from "vitest";
|
|
import type { Agent, Issue } from "@paperclipai/plugin-sdk";
|
|
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
|
|
import manifest from "../src/manifest.js";
|
|
import plugin from "../src/worker.js";
|
|
|
|
const PLUGIN_ORIGIN = `plugin:${manifest.id}` as const;
|
|
|
|
function issue(input: Partial<Issue> & Pick<Issue, "id" | "companyId" | "title">): Issue {
|
|
const now = new Date();
|
|
const { id, companyId, title, ...rest } = input;
|
|
return {
|
|
id,
|
|
companyId,
|
|
projectId: null,
|
|
projectWorkspaceId: null,
|
|
goalId: null,
|
|
parentId: null,
|
|
title,
|
|
description: null,
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: null,
|
|
assigneeUserId: null,
|
|
checkoutRunId: null,
|
|
executionRunId: null,
|
|
executionAgentNameKey: null,
|
|
executionLockedAt: null,
|
|
createdByAgentId: null,
|
|
createdByUserId: null,
|
|
issueNumber: null,
|
|
identifier: null,
|
|
originKind: "manual",
|
|
originId: null,
|
|
originRunId: null,
|
|
requestDepth: 0,
|
|
billingCode: null,
|
|
assigneeAdapterOverrides: null,
|
|
executionWorkspaceId: null,
|
|
executionWorkspacePreference: null,
|
|
executionWorkspaceSettings: null,
|
|
startedAt: null,
|
|
completedAt: null,
|
|
cancelledAt: null,
|
|
hiddenAt: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
...rest,
|
|
};
|
|
}
|
|
|
|
function agent(input: Partial<Agent> & Pick<Agent, "id" | "companyId" | "name" | "status">): Agent {
|
|
const now = new Date();
|
|
const { id, companyId, name, status, ...rest } = input;
|
|
return {
|
|
id,
|
|
companyId,
|
|
name,
|
|
urlKey: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
|
|
role: "engineer",
|
|
title: null,
|
|
icon: null,
|
|
status,
|
|
reportsTo: null,
|
|
capabilities: null,
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
budgetMonthlyCents: 100000,
|
|
spentMonthlyCents: 0,
|
|
pauseReason: null,
|
|
pausedAt: null,
|
|
permissions: { canCreateAgents: false },
|
|
lastHeartbeatAt: null,
|
|
metadata: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
...rest,
|
|
};
|
|
}
|
|
|
|
async function upsertMissionDocuments(
|
|
harness: ReturnType<typeof createTestHarness>,
|
|
companyId: string,
|
|
issueId: string,
|
|
) {
|
|
const docs = new Map<string, string>([
|
|
["plan", "# Mission Plan\n"],
|
|
["mission-brief", "# Mission Brief\n"],
|
|
[
|
|
"validation-contract",
|
|
JSON.stringify(
|
|
{
|
|
assertions: [
|
|
{
|
|
id: "VAL-MISSION-001",
|
|
title: "Mission validation",
|
|
user_value: "Advance loop works",
|
|
scope: "Mission runtime",
|
|
setup: "Plugin test harness",
|
|
steps: ["Advance the mission"],
|
|
oracle: "Only valid issues are woken",
|
|
tooling: ["manual_review"],
|
|
evidence: [{ kind: "primary", description: "Mission summary", required: true }],
|
|
claimed_by: ["FEAT-MISSION-001"],
|
|
status: "unclaimed",
|
|
},
|
|
],
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
],
|
|
[
|
|
"features",
|
|
JSON.stringify(
|
|
{
|
|
milestones: [
|
|
{
|
|
id: "MILESTONE-MISSION-001",
|
|
title: "Core Mission Flow",
|
|
summary: "Advance and validation loop",
|
|
features: [
|
|
{
|
|
id: "FEAT-MISSION-001",
|
|
title: "Advance mission",
|
|
kind: "original",
|
|
summary: "Drive assigned work forward",
|
|
acceptance_criteria: ["Wake assigned and unblocked work"],
|
|
claimed_assertion_ids: ["VAL-MISSION-001"],
|
|
status: "planned",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
],
|
|
["worker-guidelines", "# Worker Guidelines\n"],
|
|
["services", "# Services\n"],
|
|
["knowledge-base", "# Knowledge Base\n"],
|
|
["decision-log", "# Decision Log\n"],
|
|
]);
|
|
|
|
for (const [key, body] of docs) {
|
|
await harness.ctx.issues.documents.upsert({
|
|
issueId,
|
|
companyId,
|
|
key,
|
|
title: key,
|
|
body,
|
|
changeSummary: `Seeded ${key}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
describe("missions plugin package", () => {
|
|
it("declares mission initialization surfaces", () => {
|
|
expect(manifest).toMatchObject({
|
|
id: "paperclip.missions",
|
|
database: {
|
|
namespaceSlug: "missions",
|
|
migrationsDir: "migrations",
|
|
coreReadTables: ["issues"],
|
|
},
|
|
});
|
|
expect(manifest.apiRoutes).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ routeKey: "initialize-mission", path: "/issues/:issueId/missions/init" }),
|
|
expect.objectContaining({ routeKey: "mission-summary", path: "/issues/:issueId/missions/summary" }),
|
|
]),
|
|
);
|
|
expect(manifest.ui?.slots).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ type: "page", exportName: "MissionsPage" }),
|
|
expect.objectContaining({ type: "taskDetailView", exportName: "MissionIssuePanel" }),
|
|
expect.objectContaining({ type: "toolbarButton", exportName: "MissionToolbarButton" }),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("initializes a root mission, creates required documents, and returns a draft summary", async () => {
|
|
const companyId = randomUUID();
|
|
const rootIssueId = randomUUID();
|
|
const harness = createTestHarness({ manifest });
|
|
harness.seed({
|
|
issues: [
|
|
issue({
|
|
id: rootIssueId,
|
|
companyId,
|
|
title: "Initialize the mission root",
|
|
identifier: "TST-1",
|
|
}),
|
|
],
|
|
});
|
|
await plugin.definition.setup(harness.ctx);
|
|
|
|
const result = await harness.performAction<{
|
|
created: boolean;
|
|
createdDocumentKeys: string[];
|
|
summary: {
|
|
isMission: boolean;
|
|
state: string | null;
|
|
persistence: { hasRootLink: boolean; hasInitializationEvent: boolean } | null;
|
|
};
|
|
}>("initialize-mission", {
|
|
companyId,
|
|
issueId: rootIssueId,
|
|
});
|
|
|
|
expect(result.created).toBe(true);
|
|
expect(result.createdDocumentKeys).toHaveLength(8);
|
|
expect(result.summary).toMatchObject({
|
|
isMission: true,
|
|
state: "draft",
|
|
persistence: {
|
|
hasRootLink: true,
|
|
hasInitializationEvent: true,
|
|
},
|
|
});
|
|
|
|
const rootIssue = await harness.ctx.issues.get(rootIssueId, companyId);
|
|
expect(rootIssue).toMatchObject({
|
|
originKind: "plugin:paperclip.missions",
|
|
originId: `mission:${rootIssueId}`,
|
|
billingCode: `mission:${rootIssueId}`,
|
|
});
|
|
|
|
const docs = await harness.ctx.issues.documents.list(rootIssueId, companyId);
|
|
expect(new Set(docs.map((document) => document.key))).toEqual(
|
|
new Set([
|
|
"plan",
|
|
"mission-brief",
|
|
"validation-contract",
|
|
"features",
|
|
"worker-guidelines",
|
|
"services",
|
|
"knowledge-base",
|
|
"decision-log",
|
|
]),
|
|
);
|
|
|
|
expect(harness.dbExecutes.some((entry) => entry.sql.includes(".missions"))).toBe(true);
|
|
expect(harness.dbExecutes.some((entry) => entry.sql.includes(".mission_issue_links"))).toBe(true);
|
|
expect(harness.dbExecutes.some((entry) => entry.sql.includes(".mission_events"))).toBe(true);
|
|
});
|
|
|
|
it("keeps initialization idempotent and surfaces parse errors in the summary", async () => {
|
|
const companyId = randomUUID();
|
|
const rootIssueId = randomUUID();
|
|
const harness = createTestHarness({ manifest });
|
|
harness.seed({
|
|
issues: [
|
|
issue({
|
|
id: rootIssueId,
|
|
companyId,
|
|
title: "Mission with parser errors",
|
|
}),
|
|
],
|
|
});
|
|
await plugin.definition.setup(harness.ctx);
|
|
|
|
await harness.performAction("initialize-mission", { companyId, issueId: rootIssueId });
|
|
const second = await harness.performAction<{
|
|
created: boolean;
|
|
createdDocumentKeys: string[];
|
|
summary: { isMission: boolean; state: string | null };
|
|
}>("initialize-mission", { companyId, issueId: rootIssueId });
|
|
expect(second.created).toBe(false);
|
|
expect(second.createdDocumentKeys).toEqual([]);
|
|
expect(second.summary).toMatchObject({
|
|
isMission: true,
|
|
state: "draft",
|
|
});
|
|
|
|
await harness.ctx.issues.documents.upsert({
|
|
issueId: rootIssueId,
|
|
companyId,
|
|
key: "validation-contract",
|
|
title: "Validation Contract",
|
|
body: "{ not-valid-json }",
|
|
});
|
|
const summary = await harness.getData<{
|
|
isMission: boolean;
|
|
parseProblems: Array<{ key: string }>;
|
|
}>("mission-summary", {
|
|
companyId,
|
|
issueId: rootIssueId,
|
|
});
|
|
expect(summary.isMission).toBe(true);
|
|
expect(summary.parseProblems).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ key: "validation-contract" }),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("wakes assigned unblocked work and creates a single fix issue for blocking findings", async () => {
|
|
const companyId = randomUUID();
|
|
const rootIssueId = randomUUID();
|
|
const readyFeatureId = randomUUID();
|
|
const validationIssueId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const harness = createTestHarness({ manifest });
|
|
harness.seed({
|
|
agents: [agent({ id: agentId, companyId, name: "Worker", status: "active" })],
|
|
issues: [
|
|
issue({
|
|
id: rootIssueId,
|
|
companyId,
|
|
title: "Mission root",
|
|
assigneeAgentId: agentId,
|
|
originKind: PLUGIN_ORIGIN,
|
|
}),
|
|
issue({
|
|
id: readyFeatureId,
|
|
companyId,
|
|
parentId: rootIssueId,
|
|
title: "Ready feature",
|
|
assigneeAgentId: agentId,
|
|
originKind: `${PLUGIN_ORIGIN}:feature`,
|
|
originId: "FEAT-MISSION-001",
|
|
}),
|
|
issue({
|
|
id: validationIssueId,
|
|
companyId,
|
|
parentId: rootIssueId,
|
|
title: "Validation round",
|
|
status: "done",
|
|
assigneeAgentId: agentId,
|
|
originKind: `${PLUGIN_ORIGIN}:validation`,
|
|
originId: "VAL-ROUND-001",
|
|
}),
|
|
],
|
|
});
|
|
await plugin.definition.setup(harness.ctx);
|
|
await upsertMissionDocuments(harness, companyId, rootIssueId);
|
|
await harness.ctx.issues.documents.upsert({
|
|
issueId: validationIssueId,
|
|
companyId,
|
|
key: "validation-report-round-1",
|
|
title: "Validation Report Round 1",
|
|
body: JSON.stringify({
|
|
round: 1,
|
|
validator_role: "scrutiny_validator",
|
|
summary: "Found one blocking issue",
|
|
findings: [
|
|
{
|
|
id: "FINDING-MISSION-001",
|
|
severity: "blocking",
|
|
assertion_id: "VAL-MISSION-001",
|
|
title: "Advance skips fix loop",
|
|
evidence: ["Validation report"],
|
|
repro_steps: ["Run advance mission"],
|
|
expected: "Blocking findings create one fix issue",
|
|
actual: "No fix issue existed",
|
|
status: "open",
|
|
},
|
|
],
|
|
}),
|
|
changeSummary: "Seeded validation findings",
|
|
});
|
|
|
|
const firstAdvance = await harness.performAction<{
|
|
outcome: string;
|
|
createdFixIssueIds: string[];
|
|
wokenIssueIds: string[];
|
|
}>("advance-mission", {
|
|
companyId,
|
|
issueId: rootIssueId,
|
|
actorAgentId: agentId,
|
|
actorRunId: "run_1",
|
|
});
|
|
const secondAdvance = await harness.performAction<{ createdFixIssueIds: string[] }>("advance-mission", {
|
|
companyId,
|
|
issueId: rootIssueId,
|
|
actorAgentId: agentId,
|
|
actorRunId: "run_2",
|
|
});
|
|
|
|
const fixIssues = await harness.ctx.issues.list({
|
|
companyId,
|
|
originKind: `${PLUGIN_ORIGIN}:fix`,
|
|
});
|
|
|
|
expect(firstAdvance.outcome).toBe("woke_issues");
|
|
expect(firstAdvance.wokenIssueIds).toEqual(expect.arrayContaining([readyFeatureId]));
|
|
expect(firstAdvance.createdFixIssueIds).toHaveLength(1);
|
|
expect(secondAdvance.createdFixIssueIds).toEqual(firstAdvance.createdFixIssueIds);
|
|
expect(fixIssues).toHaveLength(1);
|
|
});
|
|
|
|
it("requires waiver rationale and records approved waivers", async () => {
|
|
const companyId = randomUUID();
|
|
const rootIssueId = randomUUID();
|
|
const validationIssueId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const harness = createTestHarness({ manifest });
|
|
harness.seed({
|
|
agents: [agent({ id: agentId, companyId, name: "Worker", status: "active" })],
|
|
issues: [
|
|
issue({
|
|
id: rootIssueId,
|
|
companyId,
|
|
title: "Mission root",
|
|
assigneeAgentId: agentId,
|
|
originKind: PLUGIN_ORIGIN,
|
|
}),
|
|
issue({
|
|
id: validationIssueId,
|
|
companyId,
|
|
parentId: rootIssueId,
|
|
title: "Validation round",
|
|
status: "done",
|
|
assigneeAgentId: agentId,
|
|
originKind: `${PLUGIN_ORIGIN}:validation`,
|
|
originId: "VAL-ROUND-001",
|
|
}),
|
|
],
|
|
});
|
|
await plugin.definition.setup(harness.ctx);
|
|
await upsertMissionDocuments(harness, companyId, rootIssueId);
|
|
await harness.ctx.issues.documents.upsert({
|
|
issueId: validationIssueId,
|
|
companyId,
|
|
key: "validation-report-round-1",
|
|
title: "Validation Report Round 1",
|
|
body: JSON.stringify({
|
|
round: 1,
|
|
validator_role: "scrutiny_validator",
|
|
summary: "Found one blocking issue",
|
|
findings: [
|
|
{
|
|
id: "FINDING-MISSION-001",
|
|
severity: "blocking",
|
|
assertion_id: "VAL-MISSION-001",
|
|
title: "Advance skips fix loop",
|
|
evidence: ["Validation report"],
|
|
repro_steps: ["Run advance mission"],
|
|
expected: "Blocking findings create one fix issue",
|
|
actual: "No fix issue existed",
|
|
status: "open",
|
|
},
|
|
],
|
|
}),
|
|
changeSummary: "Seeded validation findings",
|
|
});
|
|
|
|
await expect(
|
|
plugin.definition.onApiRequest?.({
|
|
routeKey: "waive-mission-finding",
|
|
method: "POST",
|
|
path: `/issues/${rootIssueId}/missions/findings/FINDING-MISSION-001/waive`,
|
|
params: { issueId: rootIssueId, findingKey: "FINDING-MISSION-001" },
|
|
query: {},
|
|
body: {},
|
|
actor: {
|
|
actorType: "user",
|
|
actorId: "board",
|
|
userId: "board",
|
|
agentId: null,
|
|
runId: null,
|
|
},
|
|
companyId,
|
|
headers: {},
|
|
}),
|
|
).resolves.toMatchObject({
|
|
status: 422,
|
|
body: { error: "rationale is required" },
|
|
});
|
|
|
|
await expect(
|
|
plugin.definition.onApiRequest?.({
|
|
routeKey: "waive-mission-finding",
|
|
method: "POST",
|
|
path: `/issues/${rootIssueId}/missions/findings/FINDING-MISSION-001/waive`,
|
|
params: { issueId: rootIssueId, findingKey: "FINDING-MISSION-001" },
|
|
query: {},
|
|
body: { rationale: "Known acceptable risk for this mission" },
|
|
actor: {
|
|
actorType: "user",
|
|
actorId: "board",
|
|
userId: "board",
|
|
agentId: null,
|
|
runId: null,
|
|
},
|
|
companyId,
|
|
headers: {},
|
|
}),
|
|
).resolves.toMatchObject({
|
|
status: 200,
|
|
body: expect.objectContaining({
|
|
waived: true,
|
|
findingId: "FINDING-MISSION-001",
|
|
}),
|
|
});
|
|
});
|
|
|
|
it("enforces checkout ownership for agent-triggered initialization on in-progress issues", async () => {
|
|
const companyId = randomUUID();
|
|
const rootIssueId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const runId = randomUUID();
|
|
const harness = createTestHarness({ manifest });
|
|
harness.seed({
|
|
issues: [
|
|
issue({
|
|
id: rootIssueId,
|
|
companyId,
|
|
title: "Checked out mission root",
|
|
status: "in_progress",
|
|
assigneeAgentId: agentId,
|
|
checkoutRunId: runId,
|
|
}),
|
|
],
|
|
});
|
|
await plugin.definition.setup(harness.ctx);
|
|
|
|
await expect(
|
|
plugin.definition.onApiRequest?.({
|
|
routeKey: "initialize-mission",
|
|
method: "POST",
|
|
path: `/issues/${rootIssueId}/missions/init`,
|
|
params: { issueId: rootIssueId },
|
|
query: {},
|
|
body: {},
|
|
actor: {
|
|
actorType: "agent",
|
|
actorId: agentId,
|
|
agentId,
|
|
userId: null,
|
|
runId: randomUUID(),
|
|
},
|
|
companyId,
|
|
headers: {},
|
|
}),
|
|
).rejects.toThrow("Issue run ownership conflict");
|
|
});
|
|
});
|