fix(routing): comment→CEO wakeup read issue id from event.entityId, not payload.issueId
The `issue.comment.created` handler read `payload.issueId`, which the current Paperclip host never sends — the issue id arrives in `event.entityId` and the payload carries `commentId`/`bodySnippet`/`reopened` instead. So the handler hit "missing issueId, skipping" on every user comment and the documented "user comment → CEO" routing was silently dead. Diagnosed on case 8124-09-24 (CMPA): a question commented on an analyst's `done` sub-issue never reached the CEO. The plugin skipped; Paperclip's native reopen-on-comment wake then targeted the sub-issue's assignee (the analyst) and the queued CEO run was cancelled with `issue_assignee_changed`. Fix: - issueId from `event.entityId` (fallback `payload.issueId` for host-version skew). - Resolve full comment body by matching `payload.commentId` in listComments (payload only has a truncated `bodySnippet`); fall back to latest/snippet. - Dedup guard: when the comment reopened an issue ALREADY assigned to the CEO, the host's native wake covers it — skip to avoid double-running the CEO. For issues owned by any other agent we still route to the CEO. Invariants: upholds the "user comment routes through CEO" contract (CLAUDE.md); touches only the platform-port shell (plugin), per G12/X15. No new parallel path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -540,27 +540,36 @@ const plugin = definePlugin({
|
|||||||
if (event.actorType !== "user") return;
|
if (event.actorType !== "user") return;
|
||||||
if (!event.companyId) return;
|
if (!event.companyId) return;
|
||||||
|
|
||||||
// entityId is the comment ID — fetch the comment to get issueId + body
|
// Event/payload shape for `issue.comment.created` (Paperclip host):
|
||||||
const entityId = event.entityId;
|
// event.entityId → the ISSUE id (the primary entity of the event)
|
||||||
if (!entityId) return;
|
// event.payload.commentId → the comment id
|
||||||
|
// event.payload.bodySnippet → TRUNCATED body (not the full text)
|
||||||
// The event payload may contain the issueId directly
|
// event.payload.reopened / reopenedFrom → set when the comment
|
||||||
const payload = event.payload as {
|
// reopened a `done` issue (host then natively wakes its assignee)
|
||||||
|
// NOTE: the host does NOT send `payload.issueId` and does NOT send the
|
||||||
|
// full `body`. The earlier code read `payload.issueId` (always absent)
|
||||||
|
// and skipped every event — silently disabling comment→CEO routing.
|
||||||
|
// See @paperclipai/plugin-sdk index.d.ts: `issueId: event.entityId`.
|
||||||
|
const payload = (event.payload ?? null) as {
|
||||||
issueId?: string;
|
issueId?: string;
|
||||||
|
commentId?: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
|
bodySnippet?: string;
|
||||||
|
reopened?: boolean;
|
||||||
|
reopenedFrom?: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
const issueId = payload?.issueId;
|
// issueId: prefer entityId (current host), fall back to payload for
|
||||||
let commentBody = payload?.body;
|
// forward/backward compatibility with other host versions.
|
||||||
|
const issueId = event.entityId || payload?.issueId;
|
||||||
// If issueId is not in payload, try to find it from the comment entity
|
|
||||||
if (!issueId) {
|
if (!issueId) {
|
||||||
ctx.logger.warn(
|
ctx.logger.warn(
|
||||||
"issue.comment.created event missing issueId in payload, skipping",
|
"issue.comment.created event missing issueId (entityId+payload empty), skipping",
|
||||||
{ entityId, payload },
|
{ entityId: event.entityId, payload },
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const commentId = payload?.commentId;
|
||||||
|
|
||||||
// Fetch issue details for context
|
// Fetch issue details for context
|
||||||
const issue = await ctx.issues.get(issueId, event.companyId);
|
const issue = await ctx.issues.get(issueId, event.companyId);
|
||||||
@@ -571,20 +580,6 @@ const plugin = definePlugin({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If comment body not in payload, fetch from API
|
|
||||||
if (!commentBody) {
|
|
||||||
try {
|
|
||||||
const comments = await ctx.issues.listComments(
|
|
||||||
issueId,
|
|
||||||
event.companyId,
|
|
||||||
);
|
|
||||||
const latest = comments[comments.length - 1];
|
|
||||||
commentBody = latest?.body || "(לא ניתן לקרוא את התגובה)";
|
|
||||||
} catch {
|
|
||||||
commentBody = "(שגיאה בקריאת התגובה)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wake the CEO agent for this company
|
// Wake the CEO agent for this company
|
||||||
const ceoAgentId = CEO_AGENT_IDS[event.companyId];
|
const ceoAgentId = CEO_AGENT_IDS[event.companyId];
|
||||||
if (!ceoAgentId) {
|
if (!ceoAgentId) {
|
||||||
@@ -594,6 +589,45 @@ const plugin = definePlugin({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dedup against the host's native reopen-on-comment wake: when a comment
|
||||||
|
// reopens a `done` issue, the host already wakes that issue's assignee.
|
||||||
|
// If the assignee IS this company's CEO, that native wake covers us —
|
||||||
|
// invoking again would double-run the CEO. Skip ONLY in that exact case.
|
||||||
|
// For issues owned by any other agent (e.g. an analyst sub-task), the
|
||||||
|
// native wake targets the wrong agent (and the queued run is cancelled
|
||||||
|
// with `issue_assignee_changed`), so we MUST route the comment to the CEO.
|
||||||
|
if (issue.assigneeAgentId === ceoAgentId && payload?.reopened === true) {
|
||||||
|
ctx.logger.info(
|
||||||
|
"Comment reopened CEO-owned issue; native wake handles it, skipping plugin route",
|
||||||
|
{ issueId, ceoAgentId },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the full comment body. The payload only carries a truncated
|
||||||
|
// snippet, so fetch the comment list and match by id (fall back to the
|
||||||
|
// latest comment, then the snippet).
|
||||||
|
let commentBody = payload?.body;
|
||||||
|
if (!commentBody) {
|
||||||
|
try {
|
||||||
|
const comments = await ctx.issues.listComments(
|
||||||
|
issueId,
|
||||||
|
event.companyId,
|
||||||
|
);
|
||||||
|
const matched = commentId
|
||||||
|
? comments.find((c) => c.id === commentId)
|
||||||
|
: undefined;
|
||||||
|
const latest = comments[comments.length - 1];
|
||||||
|
commentBody =
|
||||||
|
matched?.body ||
|
||||||
|
latest?.body ||
|
||||||
|
payload?.bodySnippet ||
|
||||||
|
"(לא ניתן לקרוא את התגובה)";
|
||||||
|
} catch {
|
||||||
|
commentBody = payload?.bodySnippet || "(שגיאה בקריאת התגובה)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { runId } = await ctx.agents.invoke(ceoAgentId, event.companyId, {
|
const { runId } = await ctx.agents.invoke(ceoAgentId, event.companyId, {
|
||||||
prompt: [
|
prompt: [
|
||||||
@@ -608,7 +642,7 @@ const plugin = definePlugin({
|
|||||||
});
|
});
|
||||||
ctx.logger.info("Routed user comment to CEO agent", {
|
ctx.logger.info("Routed user comment to CEO agent", {
|
||||||
issueId,
|
issueId,
|
||||||
commentId: entityId,
|
commentId: commentId || null,
|
||||||
runId,
|
runId,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user