From e55d702916c4d3ddbcac49b697f879808b160f59 Mon Sep 17 00:00:00 2001 From: Michael Nguyen Date: Wed, 22 Jul 2026 15:54:46 -0700 Subject: [PATCH] feat(plugin-sdk): human-attributed issue comments for chat gateway plugins (#10050) Adds the `issue.comments.create_human_attributed` capability and `ctx.issues.createComment` `actorUserId` option, with host-side active-human-member verification. LOOA-627. Co-Authored-By: Paperclip --- doc/plugins/PLUGIN_SPEC.md | 2 + packages/plugins/sdk/README.md | 23 ++++ .../plugins/sdk/src/host-client-factory.ts | 7 + packages/plugins/sdk/src/protocol.ts | 9 +- packages/plugins/sdk/src/testing.ts | 28 +++- packages/plugins/sdk/src/types.ts | 21 ++- packages/plugins/sdk/src/worker-rpc-host.ts | 15 ++- .../sdk/tests/host-client-factory.test.ts | 74 +++++++++++ packages/shared/src/constants.ts | 1 + .../plugin-orchestration-apis.test.ts | 125 +++++++++++++++++- .../src/__tests__/plugin-sdk-testing.test.ts | 92 +++++++++++++ server/src/services/plugin-host-services.ts | 73 +++++++++- 12 files changed, 459 insertions(+), 11 deletions(-) diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md index 6dd69cc012..31a0154707 100644 --- a/doc/plugins/PLUGIN_SPEC.md +++ b/doc/plugins/PLUGIN_SPEC.md @@ -706,6 +706,7 @@ Governance helpers: - `ctx.issues.assertCheckoutOwner({ issueId, companyId, actorAgentId, actorRunId })` lets plugin actions preserve agent-run checkout ownership. - `ctx.issues.requestWakeup(issueId, companyId, options)` requests assignment wakeups through host heartbeat semantics, including terminal-status, blocker, assignee, and budget hard-stop checks. - `ctx.issues.requestWakeups(issueIds, companyId, options)` applies the same host-owned wakeup semantics to a batch and may use an idempotency key prefix for stable coordinator retries. +- `ctx.issues.createComment(issueId, body, companyId, options)` posts a comment attributed to the plugin's own agent by default (`options.authorAgentId`). Passing `options.actorUserId` instead attributes the comment to that human company member — this requires `issue.comments.create_human_attributed` in addition to `issue.comments.create`, and the host independently verifies `actorUserId` is an active human member of the company before applying it, so a plugin can never forge attribution to an arbitrary or inactive user. A human-attributed comment on a non-terminal-status issue with an assignee also triggers the same assignee wakeup a board user's comment gets. Plugin-originated issue, relation, document, comment, and wakeup mutations must write activity entries with `actorType: "plugin"` and details fields for `sourcePluginId`, `sourcePluginKey`, `initiatingActorType`, `initiatingActorId`, and `initiatingRunId` when a user or agent run initiated the plugin work. @@ -810,6 +811,7 @@ The host enforces capabilities in the SDK layer and refuses calls outside the gr - `issues.create` - `issues.update` - `issue.comments.create` +- `issue.comments.create_human_attributed` - `issue.interactions.create` - `issue.documents.write` - `issue.relations.write` diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md index 3a8b866d14..8e340f416c 100644 --- a/packages/plugins/sdk/README.md +++ b/packages/plugins/sdk/README.md @@ -334,6 +334,7 @@ Declare in `manifest.capabilities`. Grouped by scope: | | `issues.checkout` | | | `issues.wakeup` | | | `issue.comments.create` | +| | `issue.comments.create_human_attributed` | | | `issue.documents.write` | | | `issue.relations.write` | | | `activity.log.write` | @@ -569,6 +570,26 @@ const summary = await ctx.issues.summaries.getOrchestration({ }); ``` +By default, `ctx.issues.createComment` attributes the comment to the calling +plugin's own agent (`authorAgentId`). A plugin that relays a message a human +actually sent — a chat gateway bridging Slack/Telegram replies back onto an +issue, for example — can instead attribute the comment to that person by +passing `actorUserId`: + +```ts +await ctx.issues.createComment(issueId, replyText, companyId, { + actorUserId: verifiedSlackUser.paperclipUserId, +}); +``` + +This requires the `issue.comments.create_human_attributed` capability in +addition to `issue.comments.create`. The host independently verifies that +`actorUserId` is an active human member of the issue's company before +applying the comment — a plugin cannot forge attribution to an arbitrary or +inactive user id. When the issue has a non-terminal status and an assigned +agent, a human-attributed comment also wakes that assignee, the same way a +board user's comment does in the web app. + Required capabilities: | API | Capability | @@ -577,6 +598,8 @@ Required capabilities: | `ctx.issues.relations.setBlockedBy` / `addBlockers` / `removeBlockers` | `issue.relations.write` | | `ctx.issues.getSubtree` | `issue.subtree.read` | | `ctx.issues.assertCheckoutOwner` | `issues.checkout` | +| `ctx.issues.createComment` | `issue.comments.create` | +| `ctx.issues.createComment` with `actorUserId` | `issue.comments.create` + `issue.comments.create_human_attributed` | | `ctx.issues.requestWakeup` / `requestWakeups` | `issues.wakeup` | | `ctx.issues.summaries.getOrchestration` | `issues.orchestration.read` | diff --git a/packages/plugins/sdk/src/host-client-factory.ts b/packages/plugins/sdk/src/host-client-factory.ts index 027d988060..a22c10f8ed 100644 --- a/packages/plugins/sdk/src/host-client-factory.ts +++ b/packages/plugins/sdk/src/host-client-factory.ts @@ -882,6 +882,13 @@ export function createHostClientHandlers( return services.issues.listComments(params); }), "issues.createComment": gated("issues.createComment", async (params) => { + if (params.actorUserId && !capabilitySet.has("issue.comments.create_human_attributed")) { + throw new CapabilityDeniedError( + pluginId, + "issues.createComment", + "issue.comments.create_human_attributed", + ); + } return services.issues.createComment(params); }), "issues.createInteraction": gated("issues.createInteraction", async (params) => { diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 8bdb2914b3..bcb0f277df 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -1449,7 +1449,14 @@ export interface WorkerToHostMethods { result: IssueComment[], ]; "issues.createComment": [ - params: { issueId: string; body: string; companyId: string; authorAgentId?: string }, + params: { + issueId: string; + body: string; + companyId: string; + authorAgentId?: string; + /** Active human company member the comment is attributed to. Requires `issue.comments.create_human_attributed`. */ + actorUserId?: string; + }, result: IssueComment, ]; "issues.createInteraction": [ diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index 205b8caa1a..6d56802298 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -1673,18 +1673,40 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { }, async createComment(issueId, body, companyId, options) { requireCapability(manifest, capabilitySet, "issue.comments.create"); + if (options?.actorUserId) { + requireCapability(manifest, capabilitySet, "issue.comments.create_human_attributed"); + } const parentIssue = issues.get(issueId); if (!isInCompany(parentIssue, companyId)) { throw new Error(`Issue not found: ${issueId}`); } + if (options?.actorUserId) { + // Mirror the host's `requireActiveHumanMember` check so the harness + // rejects the same forged attributions production does: the actor + // must be an active `user` member of the issue's company. Seed + // members via `createTestPluginHost({ accessMembers: [...] })`. + const actorUserId = options.actorUserId; + const isActiveHumanMember = [...accessMembers.values()].some( + (member) => + member.companyId === companyId + && member.principalType === "user" + && member.principalId === actorUserId + && member.status === "active", + ); + if (!isActiveHumanMember) { + throw new Error( + `actorUserId "${actorUserId}" is not an active human member of this company`, + ); + } + } const now = new Date(); const comment: IssueComment = { id: randomUUID(), companyId: parentIssue.companyId, issueId, - authorType: options?.authorAgentId ? "agent" : "system", - authorAgentId: options?.authorAgentId ?? null, - authorUserId: null, + authorType: options?.actorUserId ? "user" : options?.authorAgentId ? "agent" : "system", + authorAgentId: options?.actorUserId ? null : options?.authorAgentId ?? null, + authorUserId: options?.actorUserId ?? null, body, presentation: null, metadata: null, diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 0b39e8a06e..f8645456ee 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -1331,6 +1331,7 @@ export interface PluginIssueSummariesClient { * - `issues.orchestration.read` for orchestration summaries * - `issue.comments.read` for `listComments` * - `issue.comments.create` for `createComment` + * - `issue.comments.create_human_attributed` for `createComment` calls that pass `actorUserId` * - `issue.interactions.create` for `createInteraction`, `suggestTasks`, `askUserQuestions`, `requestConfirmation`, and `requestCheckboxConfirmation` * - `issue.documents.read` for `documents.list` and `documents.get` * - `issue.documents.write` for `documents.upsert` and `documents.delete` @@ -1434,11 +1435,29 @@ export interface PluginIssuesClient { } & PluginIssueMutationActor, ): Promise; listComments(issueId: string, companyId: string): Promise; + /** + * Post a comment on an issue. + * + * Pass `authorAgentId` to attribute the comment to the plugin's own agent + * identity (requires `issue.comments.create`, the default). + * + * Pass `actorUserId` to attribute the comment to a real human instead — + * for example, relaying a paired chat user's reply back into the issue + * thread. Requires the additional `issue.comments.create_human_attributed` + * capability. The host independently verifies that `actorUserId` is an + * active human member of the issue's company before applying the + * attribution — a plugin can only ever attribute comments to identities + * that could have posted them in the web app. A human-attributed comment + * also participates in the normal wake-the-assignee behavior a board + * user's comment gets in the web app (subject to the same closed-issue / + * no-assignee exclusions) — unlike a plugin's own agent-attributed + * comments, which never wake anyone. + */ createComment( issueId: string, body: string, companyId: string, - options?: { authorAgentId?: string }, + options?: { authorAgentId?: string; actorUserId?: string }, ): Promise; createInteraction( issueId: string, diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index 4c53eb9226..4f40baa712 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -864,8 +864,19 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost return callHost("issues.listComments", { issueId, companyId }); }, - async createComment(issueId: string, body: string, companyId: string, options?: { authorAgentId?: string }) { - return callHost("issues.createComment", { issueId, body, companyId, authorAgentId: options?.authorAgentId }); + async createComment( + issueId: string, + body: string, + companyId: string, + options?: { authorAgentId?: string; actorUserId?: string }, + ) { + return callHost("issues.createComment", { + issueId, + body, + companyId, + authorAgentId: options?.authorAgentId, + actorUserId: options?.actorUserId, + }); }, async createInteraction(issueId: string, interaction, companyId: string, options?: { authorAgentId?: string }) { diff --git a/packages/plugins/sdk/tests/host-client-factory.test.ts b/packages/plugins/sdk/tests/host-client-factory.test.ts index 83cfb49945..b741e0b4eb 100644 --- a/packages/plugins/sdk/tests/host-client-factory.test.ts +++ b/packages/plugins/sdk/tests/host-client-factory.test.ts @@ -231,4 +231,78 @@ describe("createHostClientHandlers invocation company scope", () => { ).rejects.toBeInstanceOf(InvocationScopeDeniedError); expect(searchAudit).not.toHaveBeenCalled(); }); + + it("rejects a human-attributed createComment call when only issue.comments.create is granted", async () => { + const createComment = vi.fn(async () => ({ id: "comment-1" })); + const services = { + issues: { createComment }, + } as unknown as HostServices; + const handlers = createHostClientHandlers({ + pluginId: "paperclip.test", + capabilities: ["issue.comments.create"], + services, + }); + const context = { invocationScope: { companyId: "company-a" } }; + + await expect( + handlers["issues.createComment"]({ + issueId: "issue-a", + body: "hello", + companyId: "company-a", + actorUserId: "user-a", + }, context), + ).rejects.toBeInstanceOf(CapabilityDeniedError); + expect(createComment).not.toHaveBeenCalled(); + }); + + it("allows a human-attributed createComment call once issue.comments.create_human_attributed is also granted", async () => { + const createComment = vi.fn(async () => ({ id: "comment-1" })); + const services = { + issues: { createComment }, + } as unknown as HostServices; + const handlers = createHostClientHandlers({ + pluginId: "paperclip.test", + capabilities: ["issue.comments.create", "issue.comments.create_human_attributed"], + services, + }); + const context = { invocationScope: { companyId: "company-a" } }; + + await expect( + handlers["issues.createComment"]({ + issueId: "issue-a", + body: "hello", + companyId: "company-a", + actorUserId: "user-a", + }, context), + ).resolves.toEqual({ id: "comment-1" }); + expect(createComment).toHaveBeenCalledWith({ + issueId: "issue-a", + body: "hello", + companyId: "company-a", + actorUserId: "user-a", + }); + }); + + it("still allows a plain agent-attributed createComment call without the human-attribution capability", async () => { + const createComment = vi.fn(async () => ({ id: "comment-2" })); + const services = { + issues: { createComment }, + } as unknown as HostServices; + const handlers = createHostClientHandlers({ + pluginId: "paperclip.test", + capabilities: ["issue.comments.create"], + services, + }); + const context = { invocationScope: { companyId: "company-a" } }; + + await expect( + handlers["issues.createComment"]({ + issueId: "issue-a", + body: "hello", + companyId: "company-a", + authorAgentId: "agent-a", + }, context), + ).resolves.toEqual({ id: "comment-2" }); + expect(createComment).toHaveBeenCalled(); + }); }); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index ccfd890f65..6056b0b0b6 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1256,6 +1256,7 @@ export const PLUGIN_CAPABILITIES = [ "issues.checkout", "issues.wakeup", "issue.comments.create", + "issue.comments.create_human_attributed", "issue.interactions.create", "issue.documents.write", "projects.managed", diff --git a/server/src/__tests__/plugin-orchestration-apis.test.ts b/server/src/__tests__/plugin-orchestration-apis.test.ts index de642f30e7..d43ffbb015 100644 --- a/server/src/__tests__/plugin-orchestration-apis.test.ts +++ b/server/src/__tests__/plugin-orchestration-apis.test.ts @@ -9,10 +9,13 @@ import { agentWakeupRequests, agents, companies, + companyMemberships, costEvents, createDb, executionWorkspaces, + heartbeatRunEvents, heartbeatRuns, + issueComments, issueRelations, issues, pluginManagedResources, @@ -59,19 +62,45 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => { db = createDb(tempDb.connectionString); }, 20_000); + function isHeartbeatRunDependentFkError(error: unknown) { + const message = error instanceof Error ? `${error.message} ${String(error.cause ?? "")}` : String(error); + return ( + message.includes("heartbeat_run_events_run_id_heartbeat_runs_id_fk") + || message.includes("activity_log_run_id_heartbeat_runs_id_fk") + ); + } + + // A real (fire-and-forget) heartbeat run may still be executing in the + // background when a test's own createComment-triggered wakeup completes — + // retry deletion so that race doesn't fail cleanup with an FK violation. + async function deleteHeartbeatRunsWithDependents() { + for (let attempt = 0; attempt < 5; attempt += 1) { + await db.delete(heartbeatRunEvents); + await db.delete(activityLog); + try { + await db.delete(heartbeatRuns); + return; + } catch (error) { + if (!isHeartbeatRunDependentFkError(error) || attempt === 4) throw error; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } + } + afterEach(async () => { await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true }))); tempRoots.length = 0; - await db.delete(activityLog); await db.delete(costEvents); - await db.delete(heartbeatRuns); + await deleteHeartbeatRunsWithDependents(); await db.delete(agentWakeupRequests); await db.delete(issueRelations); + await db.delete(issueComments); await db.delete(issues); await db.delete(executionWorkspaces); await db.delete(pluginManagedResources); await db.delete(projects); await db.delete(plugins); + await db.delete(companyMemberships); await db.delete(agents); await db.delete(companies); }); @@ -731,4 +760,96 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => { outputTokens: 6, }); }); + + it("rejects a human-attributed plugin comment when actorUserId is not an active company member", async () => { + const { companyId, agentId } = await seedCompanyAndAgent(); + const issueId = randomUUID(); + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Needs human input", + status: "in_review", + priority: "medium", + assigneeAgentId: agentId, + }); + + const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub()); + await expect( + services.issues.createComment({ + issueId, + companyId, + body: "Here's my answer", + actorUserId: randomUUID(), + }), + ).rejects.toThrow("is not an active human member of this company"); + + await expect(db.select().from(agentWakeupRequests)).resolves.toHaveLength(0); + }); + + it("creates a human-attributed plugin comment and wakes the issue's assignee", async () => { + const { companyId, agentId } = await seedCompanyAndAgent(); + const humanUserId = randomUUID(); + const issueId = randomUUID(); + await db.insert(companyMemberships).values({ + companyId, + principalType: "user", + principalId: humanUserId, + status: "active", + membershipRole: "owner", + }); + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Needs human input", + status: "in_review", + priority: "medium", + assigneeAgentId: agentId, + }); + // Cap concurrency at 1 and pre-seed a running run so enqueueWakeup's + // queued-run bookkeeping is exercised without startNextQueuedRunForAgent + // going on to actually claim/execute the new run — that path spins up + // real environment/adapter orchestration this test has no business + // depending on (and which races the test's own db teardown). + await db.update(agents).set({ runtimeConfig: { heartbeat: { maxConcurrentRuns: 1 } } }).where(eq(agents.id, agentId)); + await db.insert(heartbeatRuns).values({ + companyId, + agentId, + status: "running", + invocationSource: "assignment", + contextSnapshot: {}, + }); + + const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub()); + const comment = await services.issues.createComment({ + issueId, + companyId, + body: "Here's my answer", + actorUserId: humanUserId, + }); + + expect(comment).toMatchObject({ + authorType: "user", + authorUserId: humanUserId, + authorAgentId: null, + body: "Here's my answer", + }); + + const [wakeupRequest] = await db + .select() + .from(agentWakeupRequests) + .where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, agentId))); + expect(wakeupRequest).toMatchObject({ + reason: "issue_commented", + requestedByActorType: "user", + requestedByActorId: humanUserId, + }); + expect(wakeupRequest?.status).toBe("queued"); + expect(wakeupRequest?.runId).toEqual(expect.any(String)); + + const [run] = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, wakeupRequest!.runId!)); + expect(run).toMatchObject({ agentId, companyId, status: "queued" }); + }); }); diff --git a/server/src/__tests__/plugin-sdk-testing.test.ts b/server/src/__tests__/plugin-sdk-testing.test.ts index 6839b1a1d0..c304c4ad9a 100644 --- a/server/src/__tests__/plugin-sdk-testing.test.ts +++ b/server/src/__tests__/plugin-sdk-testing.test.ts @@ -160,4 +160,96 @@ describe("plugin SDK test harness", () => { expect(JSON.stringify(comments)).not.toContain("secret plugin-visible body"); expect(JSON.stringify(comments)).not.toContain("secret plugin metadata"); }); + + it("rejects a human-attributed comment when the actorUserId is not an active member", async () => { + const manifest: PaperclipPluginManifestV1 = { + id: "paperclip.test-human-attributed-comment-unverified", + apiVersion: 1, + version: "0.1.0", + displayName: "Human-Attributed Comment (unverified)", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: ["issue.comments.create", "issue.comments.create_human_attributed"], + entrypoints: { worker: "./dist/worker.js" }, + }; + const harness = createTestHarness({ manifest }); + harness.seed({ + issues: [{ + id: "issue-1", + companyId: "company-1", + title: "Human attribution", + status: "todo", + priority: "medium", + }], + // A suspended member must not satisfy the active-human-member check — + // the harness mirrors the host's `requireActiveHumanMember` guard so a + // plugin test cannot pass an attribution production would reject. + accessMembers: [{ + id: "member-suspended", + companyId: "company-1", + principalType: "user", + principalId: "user-1", + status: "suspended", + membershipRole: "member", + grants: [], + createdAt: new Date("2026-06-03T11:00:00.000Z"), + updatedAt: new Date("2026-06-03T11:00:00.000Z"), + }], + }); + + await expect( + harness.ctx.issues.createComment("issue-1", "relayed reply", "company-1", { actorUserId: "user-1" }), + ).rejects.toThrow('actorUserId "user-1" is not an active human member of this company'); + }); + + it("attributes a comment to an active human member for a verified actorUserId", async () => { + const manifest: PaperclipPluginManifestV1 = { + id: "paperclip.test-human-attributed-comment-verified", + apiVersion: 1, + version: "0.1.0", + displayName: "Human-Attributed Comment (verified)", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: ["issue.comments.create", "issue.comments.create_human_attributed"], + entrypoints: { worker: "./dist/worker.js" }, + }; + const harness = createTestHarness({ manifest }); + harness.seed({ + issues: [{ + id: "issue-1", + companyId: "company-1", + title: "Human attribution", + status: "todo", + priority: "medium", + }], + accessMembers: [{ + id: "member-active", + companyId: "company-1", + principalType: "user", + principalId: "user-1", + status: "active", + membershipRole: "member", + grants: [], + createdAt: new Date("2026-06-03T11:00:00.000Z"), + updatedAt: new Date("2026-06-03T11:00:00.000Z"), + }], + }); + + const comment = await harness.ctx.issues.createComment( + "issue-1", + "relayed reply", + "company-1", + { actorUserId: "user-1" }, + ); + + expect(comment).toMatchObject({ + issueId: "issue-1", + authorType: "user", + authorUserId: "user-1", + authorAgentId: null, + body: "relayed reply", + }); + }); }); diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index f2798cc634..e0162431f8 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -4,6 +4,7 @@ import { agentTaskSessions as agentTaskSessionsTable, agents as agentsTable, budgetIncidents, + companyMemberships, costEvents, heartbeatRuns, invites, @@ -654,6 +655,30 @@ export function buildHostServices( return record; }; + /** + * Verify `userId` is an active human member of `companyId` before letting a + * plugin attribute a mutation to them. Mirrors the authorization bar the + * web app's own board routes apply — a plugin can only ever attribute an + * action to an identity that could have taken it in the web app itself. + * Used by any plugin capability that accepts an `actorUserId` (currently + * `createComment`'s human-attributed path). + */ + const requireActiveHumanMember = async (companyId: string, userId: string): Promise => { + const [membership] = await db + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where(and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, userId), + eq(companyMemberships.status, "active"), + )) + .limit(1); + if (!membership) { + throw new Error(`actorUserId "${userId}" is not an active human member of this company`); + } + }; + const pluginActivityDetails = ( details: Record | null | undefined, actor?: { actorAgentId?: string | null; actorUserId?: string | null; actorRunId?: string | null }, @@ -2012,23 +2037,67 @@ export function buildHostServices( const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const issue = requireInCompany("Issue", await issues.getById(params.issueId), companyId); + if (params.actorUserId) { + await requireActiveHumanMember(companyId, params.actorUserId); + } const comment = (await issues.addComment( params.issueId, params.body, - { agentId: params.authorAgentId }, + { agentId: params.actorUserId ? undefined : params.authorAgentId, userId: params.actorUserId }, )) as IssueComment; await logPluginActivity({ companyId, action: "issue.comment.created", entityType: "issue", entityId: issue.id, - actor: { actorAgentId: params.authorAgentId ?? null }, + actor: { actorAgentId: params.actorUserId ? null : params.authorAgentId ?? null, actorUserId: params.actorUserId ?? null }, details: { identifier: issue.identifier, commentId: comment.id, bodySnippet: comment.body.slice(0, 120), }, }); + + // Human-attributed comments participate in the same "wake the + // assignee" behavior a board user's comment gets in the web app + // (routes/issues.ts's addComment route) — a plugin's own + // agent-attributed comments never do this. Deliberately narrower + // than the HTTP route: no reopen/resume/interrupt/scheduled-retry + // handling here, just the core wake. An assignee-less or + // closed-status issue is a silent no-op, matching the route's own + // guard. + if ( + params.actorUserId + && issue.assigneeAgentId + && issue.status !== "done" + && issue.status !== "cancelled" + ) { + await heartbeat.wakeup(issue.assigneeAgentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { + issueId: issue.id, + commentId: comment.id, + mutation: "comment", + }, + requestedByActorType: "user", + requestedByActorId: params.actorUserId, + contextSnapshot: { + issueId: issue.id, + taskId: issue.id, + sourceCommentId: comment.id, + wakeReason: "issue_commented", + source: `plugin:${pluginKey}`, + }, + }).catch((err) => logger.warn({ + err, + issueId: issue.id, + commentId: comment.id, + agentId: issue.assigneeAgentId, + }, "failed to wake assignee on plugin-relayed human comment")); + } + return comment; }, async createInteraction(params) {