diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md index 08b859c135..b5f4d5d255 100644 --- a/doc/plugins/PLUGIN_SPEC.md +++ b/doc/plugins/PLUGIN_SPEC.md @@ -924,6 +924,10 @@ Minimum event set: - `agent.run.cancelled` - `approval.created` - `approval.decided` +- `decision.created` +- `decision.expired` +- `decision.dismissed` +- `decision.cancelled` - `budget.incident.opened` - `budget.incident.resolved` - `cost_event.created` diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 28e18d3dfd..3655e4aada 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1708,6 +1708,14 @@ export const PLUGIN_EVENT_TYPES = [ "goal.updated", "approval.created", "approval.decided", + // Decisions Desk lifecycle. A decision is the platform's "a human must choose, + // by this date" object, so excluding it left notifications unable to cover the + // surface operators most need to be woken for. The queue, triage, training and + // retention actions stay private: they are desk plumbing, not decisions. + "decision.created", + "decision.expired", + "decision.dismissed", + "decision.cancelled", "budget.incident.opened", "budget.incident.resolved", "cost_event.created", diff --git a/server/src/__tests__/plugin-decision-events.test.ts b/server/src/__tests__/plugin-decision-events.test.ts new file mode 100644 index 0000000000..e9174cf368 --- /dev/null +++ b/server/src/__tests__/plugin-decision-events.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { PLUGIN_EVENT_TYPES } from "@paperclipai/shared"; +import { eventTypeForActivityAction } from "../services/activity-log.js"; + +/** + * The activity-action → plugin-event mapping is a plugin-facing contract that + * fails silently: an unmapped action produces no event, so a subscriber simply + * never hears about it and nothing errors. The Decisions Desk was in exactly that + * state — `decision.created` was logged with the payload a notifier needs + * (`originIssueId`, `originAgentId`, `originResponsibleUserId`) but mapped to + * nothing. + */ +describe("decision lifecycle plugin events", () => { + const decisionActions = [ + "decision.created", + "decision.expired", + "decision.dismissed", + "decision.cancelled", + ] as const; + + it("declares every decision lifecycle action as a subscribable event type", () => { + for (const action of decisionActions) { + expect(PLUGIN_EVENT_TYPES).toContain(action); + } + }); + + it("maps each decision action onto its own event type", () => { + for (const action of decisionActions) { + expect(eventTypeForActivityAction(action)).toBe(action); + } + }); + + it("keeps desk plumbing out of the plugin event surface", () => { + // Queue, triage, training and retention actions are internal desk mechanics. + // Exposing them would invite plugins to depend on queue internals, and each + // one would become a compatibility obligation. + for (const action of [ + "decision_queue_item.added", + "decision_queue.created", + "decision_queue_item.removed", + "decision_triage.updated", + "decision_training.created", + "decision_retention.archived", + ]) { + expect(eventTypeForActivityAction(action)).toBeNull(); + } + }); + + it("does not treat a decision action as a prefix match", () => { + // The pass-through is an exact set lookup, not a prefix rule: a future + // action must be declared deliberately rather than inheriting an event. + expect(eventTypeForActivityAction("decision.created.v2")).toBeNull(); + expect(eventTypeForActivityAction("decision")).toBeNull(); + }); + + it("still maps the legacy underscore actions it already covered", () => { + expect(eventTypeForActivityAction("approval_approved")).toBe("approval.decided"); + expect(eventTypeForActivityAction("approval_rejected")).toBe("approval.decided"); + expect(eventTypeForActivityAction("issue_comment_added")).toBe("issue.comment.created"); + expect(eventTypeForActivityAction("budget_soft_threshold_crossed")).toBe( + "budget.incident.opened", + ); + }); + + it("passes through the event types that were already exact names", () => { + expect(eventTypeForActivityAction("issue.created")).toBe("issue.created"); + expect(eventTypeForActivityAction("approval.created")).toBe("approval.created"); + }); + + it("leaves unrelated activity unmapped", () => { + expect(eventTypeForActivityAction("company.created.note")).toBeNull(); + expect(eventTypeForActivityAction("some.unmapped.action")).toBeNull(); + }); +}); diff --git a/server/src/services/activity-log.ts b/server/src/services/activity-log.ts index 4c75f07527..64b9f1356d 100644 --- a/server/src/services/activity-log.ts +++ b/server/src/services/activity-log.ts @@ -37,7 +37,14 @@ export function setPluginEventBus(bus: PluginEventBus): void { _pluginEventBus = bus; } -function eventTypeForActivityAction(action: string): PluginEventType | null { +/** + * Resolve the plugin event type for a logged activity action. + * + * Exported for direct testing, following `resolveResponsibleUserIdForActivity`: + * the mapping is the contract plugins subscribe against, and a silent regression + * here is invisible at runtime (the event is simply never delivered). + */ +export function eventTypeForActivityAction(action: string): PluginEventType | null { if (PLUGIN_EVENT_SET.has(action)) return action as PluginEventType; return ACTIVITY_ACTION_TO_PLUGIN_EVENT[action.replaceAll(".", "_")] ?? null; }