This commit is contained in:
conreo 2026-09-13 17:20:41 +08:00 committed by GitHub
commit c7334433ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 94 additions and 1 deletions

View File

@ -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`

View File

@ -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",

View File

@ -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();
});
});

View File

@ -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;
}