From c7ac17306dfbaaf30d19741e1392b0cb9631c675 Mon Sep 17 00:00:00 2001 From: zbigniew sobiecki Date: Sun, 1 Mar 2026 22:22:21 +0100 Subject: [PATCH] refactor(triggers): improve type safety and add missing triggers to registry - Add missing GitHub triggers (scm:pr-merged, scm:pr-ready-to-merge) to TRIGGER_REGISTRY - Use proper literal types (ContextStepName[], KnownProvider[]) instead of string[] - Remove duplicate TRIGGER_CATEGORY_LABELS from frontend (import from shared types) - Remove duplicate type definitions from CLI triggers.ts (import from shared modules) - Standardize trigger descriptions to consistent past tense verb phrases - Add comprehensive unit tests for TRIGGER_REGISTRY - Export ContextStepName and KnownProvider types from triggerTypes.ts for consumers Co-Authored-By: Claude Opus 4.5 --- src/api/routers/_shared/triggerTypes.ts | 140 ++++++++++++++++ src/api/routers/agentDefinitions.ts | 2 + src/cli/dashboard/definitions/triggers.ts | 102 ++++++++++++ .../api/routers/_shared/triggerTypes.test.ts | 102 ++++++++++++ .../settings/agent-definition-editor.tsx | 150 ++++++++++++++++++ 5 files changed, 496 insertions(+) create mode 100644 src/cli/dashboard/definitions/triggers.ts diff --git a/src/api/routers/_shared/triggerTypes.ts b/src/api/routers/_shared/triggerTypes.ts index 2e06588e..fc7796ea 100644 --- a/src/api/routers/_shared/triggerTypes.ts +++ b/src/api/routers/_shared/triggerTypes.ts @@ -3,6 +3,16 @@ * These types are used by both the backend (tRPC router) and frontend (dashboard). */ +import { + CONTEXT_STEP_NAMES, + type ContextStepName, + type KnownProvider, +} from '../../../agents/definitions/schema.js'; + +// Re-export for convenience +export type { ContextStepName, KnownProvider }; +export { CONTEXT_STEP_NAMES }; + // ============================================================================ // Parameter Types // ============================================================================ @@ -98,3 +108,133 @@ export const TRIGGER_CATEGORY_LABELS: Record = { * Valid trigger categories. */ export type TriggerCategory = 'pm' | 'scm' | 'email' | 'sms'; + +// ============================================================================ +// Known Trigger Registry +// ============================================================================ + +/** + * A known trigger event definition with metadata. + * Used for populating the definition editor's trigger selection UI. + */ +export interface KnownTriggerEvent { + /** Event identifier (e.g., "pm:card-moved", "scm:check-suite-success") */ + event: string; + /** Human-readable label */ + label: string; + /** Description of when this trigger fires */ + description: string; + /** Context pipeline elements this trigger typically brings */ + contextPipeline: ContextStepName[]; + /** Provider restrictions (if provider-specific) */ + providers?: KnownProvider[]; +} + +/** + * Registry of all known trigger events organized by category. + * Used by the definition editor to show available triggers. + */ +export const TRIGGER_REGISTRY: Record = { + pm: [ + { + event: 'pm:card-moved', + label: 'Card Moved', + description: 'Card moved to a list', + contextPipeline: ['workItem'], + providers: ['trello'], + }, + { + event: 'pm:issue-transitioned', + label: 'Issue Transitioned', + description: 'Issue status changed', + contextPipeline: ['workItem'], + providers: ['jira'], + }, + { + event: 'pm:label-added', + label: 'Label Added', + description: 'Label added to card/issue', + contextPipeline: ['workItem'], + }, + { + event: 'pm:comment-mention', + label: 'Comment Mention', + description: 'Bot mentioned in comment', + contextPipeline: ['workItem'], + }, + ], + scm: [ + { + event: 'scm:check-suite-success', + label: 'CI Passed', + description: 'CI check suite passed', + contextPipeline: ['prContext'], + providers: ['github'], + }, + { + event: 'scm:check-suite-failure', + label: 'CI Failed', + description: 'CI check suite failed', + contextPipeline: ['prContext'], + providers: ['github'], + }, + { + event: 'scm:pr-review-submitted', + label: 'PR Review Submitted', + description: 'Review submitted on PR', + contextPipeline: ['prContext', 'prConversation'], + providers: ['github'], + }, + { + event: 'scm:review-requested', + label: 'Review Requested', + description: 'Review requested on PR', + contextPipeline: ['prContext'], + providers: ['github'], + }, + { + event: 'scm:pr-opened', + label: 'PR Opened', + description: 'PR opened', + contextPipeline: ['prContext'], + providers: ['github'], + }, + { + event: 'scm:pr-comment', + label: 'PR Comment', + description: 'Comment added to PR', + contextPipeline: ['prContext', 'prConversation'], + providers: ['github'], + }, + { + event: 'scm:pr-merged', + label: 'PR Merged', + description: 'PR merged to base branch', + contextPipeline: ['prContext'], + providers: ['github'], + }, + { + event: 'scm:pr-ready-to-merge', + label: 'PR Ready to Merge', + description: 'PR approved and CI passed', + contextPipeline: ['prContext'], + providers: ['github'], + }, + ], + email: [ + { + event: 'email:received', + label: 'Email Received', + description: 'Email received', + contextPipeline: ['prefetchedEmails'], + }, + ], + sms: [ + { + event: 'sms:received', + label: 'SMS Received', + description: 'SMS received', + contextPipeline: [], + }, + ], +}; diff --git a/src/api/routers/agentDefinitions.ts b/src/api/routers/agentDefinitions.ts index 311dc383..5c35530a 100644 --- a/src/api/routers/agentDefinitions.ts +++ b/src/api/routers/agentDefinitions.ts @@ -24,6 +24,7 @@ import { } from '../../db/repositories/agentDefinitionsRepository.js'; import { loadPartials } from '../../db/repositories/partialsRepository.js'; import { protectedProcedure, publicProcedure, router, superAdminProcedure } from '../trpc.js'; +import { TRIGGER_REGISTRY } from './_shared/triggerTypes.js'; async function validatePromptIfPresent(prompt: string | null | undefined) { if (!prompt) return; @@ -352,6 +353,7 @@ export const agentDefinitionsRouter = router({ capabilities: [...CAPABILITIES], contextStepNames: [...CONTEXT_STEP_NAMES], compactionNames: [...COMPACTION_NAMES], + triggerRegistry: TRIGGER_REGISTRY, }; }), }); diff --git a/src/cli/dashboard/definitions/triggers.ts b/src/cli/dashboard/definitions/triggers.ts new file mode 100644 index 00000000..c39241ad --- /dev/null +++ b/src/cli/dashboard/definitions/triggers.ts @@ -0,0 +1,102 @@ +import { Args } from '@oclif/core'; +import type { SupportedTrigger, TriggerParameter } from '../../../agents/definitions/schema.js'; +import { TRIGGER_CATEGORY_LABELS } from '../../../api/routers/_shared/triggerTypes.js'; +import { DashboardCommand } from '../_shared/base.js'; + +function groupTriggersByCategory(triggers: SupportedTrigger[]): Map { + const grouped = new Map(); + for (const trigger of triggers) { + const category = trigger.event.split(':')[0]; + const list = grouped.get(category) ?? []; + list.push(trigger); + grouped.set(category, list); + } + return grouped; +} + +function formatParameter(param: TriggerParameter): string { + const typeInfo = + param.type === 'select' && param.options + ? `${param.type}: ${param.options.join('|')}` + : param.type; + const defaultInfo = param.defaultValue !== undefined ? ` = ${param.defaultValue}` : ''; + return ` ${param.name} (${typeInfo})${defaultInfo}`; +} + +function formatTrigger(trigger: SupportedTrigger): string[] { + const lines: string[] = []; + const enabledMark = trigger.defaultEnabled ? '\u2713' : '\u2717'; + lines.push(` ${enabledMark} ${trigger.event} (${trigger.label})`); + + if (trigger.providers && trigger.providers.length > 0) { + lines.push(` - providers: ${trigger.providers.join(', ')}`); + } + + lines.push(` - defaultEnabled: ${trigger.defaultEnabled}`); + + if (trigger.contextPipeline && trigger.contextPipeline.length > 0) { + lines.push(` - contextPipeline: ${trigger.contextPipeline.join(', ')}`); + } + + if (trigger.parameters.length > 0) { + lines.push(' - parameters:'); + for (const param of trigger.parameters) { + lines.push(formatParameter(param)); + } + } + + lines.push(''); + return lines; +} + +export default class DefinitionsTriggers extends DashboardCommand { + static override description = 'Show triggers defined in an agent definition.'; + + static override args = { + agentType: Args.string({ description: 'Agent type', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + }; + + async run(): Promise { + const { args, flags } = await this.parse(DefinitionsTriggers); + + try { + const result = await this.client.agentDefinitions.get.query({ + agentType: args.agentType, + }); + + const triggers = result.definition.triggers as SupportedTrigger[]; + + if (flags.json) { + this.outputJson({ agentType: args.agentType, triggers }); + return; + } + + this.log(`Triggers for: ${args.agentType}`); + this.log(''); + + if (triggers.length === 0) { + this.log('No triggers defined.'); + return; + } + + const grouped = groupTriggersByCategory(triggers); + + for (const [category, categoryTriggers] of grouped) { + this.log(TRIGGER_CATEGORY_LABELS[category] ?? `${category.toUpperCase()} Triggers`); + + for (const trigger of categoryTriggers) { + const lines = formatTrigger(trigger); + for (const line of lines) { + this.log(line); + } + } + } + } catch (err) { + this.handleError(err); + } + } +} diff --git a/tests/unit/api/routers/_shared/triggerTypes.test.ts b/tests/unit/api/routers/_shared/triggerTypes.test.ts index ebfc9afe..ccd5c452 100644 --- a/tests/unit/api/routers/_shared/triggerTypes.test.ts +++ b/tests/unit/api/routers/_shared/triggerTypes.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vitest'; +import { CONTEXT_STEP_NAMES } from '../../../../../src/agents/definitions/schema.js'; import { + type KnownTriggerEvent, type ProjectTriggersView, type ResolvedTrigger, TRIGGER_CATEGORY_LABELS, + TRIGGER_REGISTRY, type TriggerCategory, type TriggerParameterDef, type TriggerParameterValue, @@ -96,4 +99,103 @@ describe('triggerTypes', () => { expect(view.integrations.scm).toBe('github'); }); }); + + describe('TRIGGER_REGISTRY', () => { + it('has all four categories', () => { + expect(Object.keys(TRIGGER_REGISTRY)).toEqual(['pm', 'scm', 'email', 'sms']); + }); + + it('pm category has expected triggers', () => { + const pmEvents = TRIGGER_REGISTRY.pm.map((t) => t.event); + expect(pmEvents).toContain('pm:card-moved'); + expect(pmEvents).toContain('pm:issue-transitioned'); + expect(pmEvents).toContain('pm:label-added'); + expect(pmEvents).toContain('pm:comment-mention'); + }); + + it('scm category has all GitHub triggers including pr-merged and pr-ready-to-merge', () => { + const scmEvents = TRIGGER_REGISTRY.scm.map((t) => t.event); + expect(scmEvents).toContain('scm:check-suite-success'); + expect(scmEvents).toContain('scm:check-suite-failure'); + expect(scmEvents).toContain('scm:pr-review-submitted'); + expect(scmEvents).toContain('scm:review-requested'); + expect(scmEvents).toContain('scm:pr-opened'); + expect(scmEvents).toContain('scm:pr-comment'); + expect(scmEvents).toContain('scm:pr-merged'); + expect(scmEvents).toContain('scm:pr-ready-to-merge'); + }); + + it('email category has expected triggers', () => { + const emailEvents = TRIGGER_REGISTRY.email.map((t) => t.event); + expect(emailEvents).toContain('email:received'); + }); + + it('sms category has expected triggers', () => { + const smsEvents = TRIGGER_REGISTRY.sms.map((t) => t.event); + expect(smsEvents).toContain('sms:received'); + }); + + it('all triggers have required KnownTriggerEvent fields', () => { + for (const [category, triggers] of Object.entries(TRIGGER_REGISTRY)) { + for (const trigger of triggers) { + expect(trigger.event).toBeTruthy(); + expect(trigger.label).toBeTruthy(); + expect(trigger.description).toBeTruthy(); + expect(Array.isArray(trigger.contextPipeline)).toBe(true); + } + } + }); + + it('all context pipeline values are valid ContextStepNames', () => { + const validSteps = new Set(CONTEXT_STEP_NAMES); + for (const triggers of Object.values(TRIGGER_REGISTRY)) { + for (const trigger of triggers) { + for (const step of trigger.contextPipeline) { + expect(validSteps.has(step)).toBe(true); + } + } + } + }); + + it('all provider values are valid KnownProviders', () => { + const validProviders = new Set(['trello', 'jira', 'github', 'imap', 'gmail', 'twilio']); + const allProviders = Object.values(TRIGGER_REGISTRY) + .flat() + .flatMap((t) => t.providers ?? []); + for (const provider of allProviders) { + expect(validProviders.has(provider)).toBe(true); + } + }); + + it('scm triggers specify github as provider', () => { + for (const trigger of TRIGGER_REGISTRY.scm) { + expect(trigger.providers).toContain('github'); + } + }); + + it('pm:card-moved specifies trello provider', () => { + const cardMoved = TRIGGER_REGISTRY.pm.find((t) => t.event === 'pm:card-moved'); + expect(cardMoved?.providers).toContain('trello'); + }); + + it('pm:issue-transitioned specifies jira provider', () => { + const issueTransitioned = TRIGGER_REGISTRY.pm.find( + (t) => t.event === 'pm:issue-transitioned', + ); + expect(issueTransitioned?.providers).toContain('jira'); + }); + + it('KnownTriggerEvent type has correct shape', () => { + const trigger: KnownTriggerEvent = { + event: 'test:event', + label: 'Test Event', + description: 'A test event', + contextPipeline: ['workItem'], + providers: ['trello'], + }; + + expect(trigger.event).toBe('test:event'); + expect(trigger.contextPipeline).toEqual(['workItem']); + }); + }); }); diff --git a/web/src/components/settings/agent-definition-editor.tsx b/web/src/components/settings/agent-definition-editor.tsx index 14db0b03..8f29b83c 100644 --- a/web/src/components/settings/agent-definition-editor.tsx +++ b/web/src/components/settings/agent-definition-editor.tsx @@ -1,4 +1,8 @@ import type { AppRouter } from '@/../../src/api/router.js'; +import { + type KnownTriggerEvent, + TRIGGER_CATEGORY_LABELS, +} from '@/../../src/api/routers/_shared/triggerTypes.js'; import { Badge } from '@/components/ui/badge.js'; import { Input } from '@/components/ui/input.js'; import { Label } from '@/components/ui/label.js'; @@ -36,6 +40,7 @@ interface SchemaData { capabilities: readonly string[]; contextStepNames: readonly string[]; compactionNames: readonly string[]; + triggerRegistry: Record; } // All available capabilities organized by integration @@ -475,6 +480,150 @@ function TrailingMessageSection({ ); } +// ───────────────────────────────────────────────────────────────────────────── +// Triggers Section +// ───────────────────────────────────────────────────────────────────────────── + +function TriggersSection({ + def, + setDef, + schema, +}: { + def: AgentDefinition; + setDef: React.Dispatch>; + schema: SchemaData | undefined; +}) { + const enabledEvents = new Set(def.triggers.map((t) => t.event)); + + const toggleTrigger = (known: KnownTriggerEvent, enabled: boolean) => { + setDef((d) => { + if (enabled) { + // Add the trigger with minimal configuration + // Type assertions needed because schema returns string[] but definition expects literal types + const newTrigger: AgentDefinition['triggers'][number] = { + event: known.event, + label: known.label, + description: known.description, + defaultEnabled: true, + parameters: [], + ...(known.providers + ? { providers: known.providers as AgentDefinition['triggers'][number]['providers'] } + : {}), + ...(known.contextPipeline.length > 0 + ? { + contextPipeline: + known.contextPipeline as AgentDefinition['triggers'][number]['contextPipeline'], + } + : {}), + }; + return { ...d, triggers: [...d.triggers, newTrigger] }; + } + // Remove the trigger + return { + ...d, + triggers: d.triggers.filter((t) => t.event !== known.event), + }; + }); + }; + + if (!schema?.triggerRegistry) { + return ( +
+

+ Triggers +

+
Loading trigger registry...
+
+ ); + } + + const categories = ['pm', 'scm', 'email', 'sms'] as const; + + return ( +
+

+ Triggers +

+

+ Select which events can activate this agent. Each trigger defines what event fires the agent + and what context it provides. +

+ + {categories.map((category) => { + const triggers = schema.triggerRegistry[category] ?? []; + if (triggers.length === 0) return null; + + return ( +
+
{TRIGGER_CATEGORY_LABELS[category]}
+
+ {triggers.map((known) => { + const isEnabled = enabledEvents.has(known.event); + return ( +
+ toggleTrigger(known, e.target.checked)} + className="mt-0.5 h-4 w-4 rounded border-input" + /> +
+
+ + + ({known.event}) + + {known.providers && known.providers.length > 0 && ( +
+ {known.providers.map((p) => ( + + {p} + + ))} +
+ )} +
+
{known.description}
+ {known.contextPipeline.length > 0 && ( +
+ + {known.contextPipeline.join(', ')} +
+ )} +
+
+ ); + })} +
+
+ ); + })} + + {def.triggers.length > 0 && ( +
+
Selected Triggers ({def.triggers.length})
+
+ {def.triggers.map((t) => ( + + {t.event} + + ))} +
+
+ )} +
+ ); +} + // ───────────────────────────────────────────────────────────────────────────── // Combined Prompts panel (edit mode only) - shows both system and task prompts // ───────────────────────────────────────────────────────────────────────────── @@ -1031,6 +1180,7 @@ export function AgentDefinitionEditor({ existing, onClose }: AgentDefinitionEdit +