diff --git a/server/src/__tests__/plugin-orchestration-apis.test.ts b/server/src/__tests__/plugin-orchestration-apis.test.ts index 968c01ccfe2..8712d8ab5b3 100644 --- a/server/src/__tests__/plugin-orchestration-apis.test.ts +++ b/server/src/__tests__/plugin-orchestration-apis.test.ts @@ -1036,6 +1036,42 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => { }); }); + it("respondInteraction rejects duplicate checkbox option ids at the plugin boundary", async () => { + const { companyId } = await seedCompanyAndAgent(); + const operatorUserId = randomUUID(); + await db.insert(companyMemberships).values({ + companyId, principalType: "user", principalId: operatorUserId, status: "active", membershipRole: "operator", + }); + const issueId = randomUUID(); + await db.insert(issues).values({ + id: issueId, companyId, title: "Choose campaigns", status: "in_review", priority: "medium", + }); + const interactionId = await seedInteraction(companyId, issueId, { + kind: "request_checkbox_confirmation", + payload: { + version: 1, + prompt: "Which spikes should become campaigns?", + options: [{ id: "spike-a", label: "Spike A" }], + defaultSelectedOptionIds: [], + minSelected: 0, + maxSelected: 1, + } as never, + }); + const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub()); + + await expect(services.issues.respondInteraction({ + issueId, + interactionId, + companyId, + action: "accept", + actorUserId: operatorUserId, + selectedOptionIds: ["spike-a", "spike-a"], + })).rejects.toThrow("selectedOptionIds must be unique"); + + const [row] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId)); + expect(row?.status).toBe("pending"); + }); + it("respondInteraction converges (applied:false) when the interaction is already resolved", async () => { const { companyId, agentId } = await seedCompanyAndAgent(); const humanUserId = randomUUID(); diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index b1753cfd87f..1d219291c56 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -28,7 +28,7 @@ import type { PluginExecutionWorkspaceMetadata, } from "@paperclipai/plugin-sdk"; import type { CreateIssueThreadInteraction, InviteJoinType, IssueDocumentSummary, PermissionKey, PrincipalType } from "@paperclipai/shared"; -import { pluginOperationIssueOriginKind } from "@paperclipai/shared"; +import { acceptIssueThreadInteractionSchema, pluginOperationIssueOriginKind } from "@paperclipai/shared"; import { companyService } from "./companies.js"; import { agentService } from "./agents.js"; import { projectService } from "./projects.js"; @@ -2286,12 +2286,13 @@ export function buildHostServices( status: issue.status, }; if (params.action === "accept") { + const acceptInput = acceptIssueThreadInteractionSchema.parse({ + selectedOptionIds: params.selectedOptionIds, + }); const result = await interactions.acceptInteraction( { id: issue.id, companyId, projectId: issue.projectId ?? null, goalId: issue.goalId ?? null }, params.interactionId, - params.selectedOptionIds === undefined - ? {} - : { selectedOptionIds: params.selectedOptionIds }, + acceptInput, actor, ); resolved = result.interaction as typeof current;