diff --git a/packages/deploy/src/compile-agent.test.ts b/packages/deploy/src/compile-agent.test.ts index 24a47810..88e04bb9 100644 --- a/packages/deploy/src/compile-agent.test.ts +++ b/packages/deploy/src/compile-agent.test.ts @@ -98,6 +98,43 @@ test('unknown capability extensions survive source-to-persistence projection', a } }); +test('trigger paths survive source-to-persistence projection for wake scoping', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'compiled-agent-trigger-paths-')); + try { + const sourcePath = path.join(dir, 'agent.ts'); + const source = PRESET + .replace('integrations: { github: {} },', 'integrations: { github: {}, slack: {} },') + .replace( + "triggers: { github: [{ on: 'pull_request.opened' }] },", + "triggers: {\n" + + " github: [{ on: 'pull_request.opened', paths: ['/github/repos/AgentWorkforce/workforce/pulls/**'] }],\n" + + " slack: [{ on: 'message.created', paths: ['/slack/channels/C_REVIEW/**'] }]\n" + + ' },' + ); + await writeFile(sourcePath, source); + + const compiled = await compileAgentSource(sourcePath); + const persisted = projectCompiledAgentForPersistence(compiled); + + assert.deepEqual(persisted.agent.triggers, { + github: [ + { + on: 'pull_request.opened', + paths: ['/github/repos/AgentWorkforce/workforce/pulls/**'] + } + ], + slack: [ + { + on: 'message.created', + paths: ['/slack/channels/C_REVIEW/**'] + } + ] + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + test('split persona plus agent form remains supported', async () => { const dir = await mkdtemp(path.join(tmpdir(), 'compiled-agent-split-')); try { diff --git a/packages/persona-kit/schemas/agent.schema.json b/packages/persona-kit/schemas/agent.schema.json index 51bb0df6..af6771ba 100644 --- a/packages/persona-kit/schemas/agent.schema.json +++ b/packages/persona-kit/schemas/agent.schema.json @@ -49,6 +49,16 @@ "where": { "type": "string" }, + "paths": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "pattern": "^/(?:[^\\r\\n\\u2028\\u2029]*\\S)?$" + }, + "description": "Optional Relayfile path globs used to scope wake routing before an agent is provisioned. Paths are absolute (leading `/`) and provider-specific.", + "minItems": 1 + }, "maxConcurrency": { "type": "integer", "description": "Optional delivery backpressure hint for this trigger. Positive integers are preserved by the parser; invalid values are treated as unset so older or malformed specs do not block delivery.", @@ -58,7 +68,7 @@ "required": [ "on" ], - "description": "A single event trigger declared by an **agent** (`agent.ts`). `on` is a Relayfile-adapter-normalized event name (e.g. `pull_request.opened`, `issue.create`, `message.created`). `match` and `where` are filter sugars the deploy CLI lints against a known registry; unknown values warn but do not fail parse, so the cloud runtime stays the source of truth.\n\nTriggers live on the agent ( {@link AgentSpec.triggers } ), not the persona — the persona only declares which providers it connects to ( {@link PersonaIntegrationConfig } ). The deploy CLI joins the two: events from `agent.triggers[provider]`, connection/scope from `persona.integrations[provider]`.\n\nExamples: { on: \"pull_request.opened\" } { on: \"pull_request_review_comment.created\", match: \"@mention\" } { on: \"check_run.completed\", where: \"conclusion=failure\" } { on: \"message.created\", maxConcurrency: 1 }" + "description": "A single event trigger declared by an **agent** (`agent.ts`). `on` is a Relayfile-adapter-normalized event name (e.g. `pull_request.opened`, `issue.create`, `message.created`). `match` and `where` are filter sugars the deploy CLI lints against a known registry; unknown values warn but do not fail parse, so the cloud runtime stays the source of truth.\n\nTriggers live on the agent ( {@link AgentSpec.triggers } ), not the persona — the persona only declares which providers it connects to ( {@link PersonaIntegrationConfig } ). The deploy CLI joins the two: events from `agent.triggers[provider]`, connection/scope from `persona.integrations[provider]`.\n\nExamples: { on: \"pull_request.opened\" } { on: \"pull_request_review_comment.created\", match: \"@mention\" } { on: \"check_run.completed\", where: \"conclusion=failure\" } { on: \"message.created\", paths: [\"/slack/channels/C_REVIEW/**\"] } { on: \"message.created\", maxConcurrency: 1 }" }, "PersonaSchedule": { "type": "object", @@ -85,8 +95,11 @@ "paths": { "type": "array", "items": { - "type": "string" - } + "type": "string", + "minLength": 1, + "pattern": "^/(?:[^\\r\\n\\u2028\\u2029]*\\S)?$" + }, + "minItems": 1 }, "events": { "type": "array", diff --git a/packages/persona-kit/scripts/emit-schema.mjs b/packages/persona-kit/scripts/emit-schema.mjs index a046f28e..aa324c7d 100644 --- a/packages/persona-kit/scripts/emit-schema.mjs +++ b/packages/persona-kit/scripts/emit-schema.mjs @@ -146,6 +146,23 @@ if ( }; } +const absolutePathArrayConstraint = { + minItems: 1, + items: { + type: 'string', + minLength: 1, + pattern: '^/(?:[^\\r\\n\\u2028\\u2029]*\\S)?$' + } +}; + +function applyAbsolutePathArrayConstraint(pathsSchema) { + if (pathsSchema?.type !== 'array') return; + Object.assign(pathsSchema, absolutePathArrayConstraint); +} + +applyAbsolutePathArrayConstraint(agentTrigger?.properties?.paths); +applyAbsolutePathArrayConstraint(agentSchema.definitions?.WatchRule?.properties?.paths); + const agentSerialized = `${JSON.stringify(agentSchema, null, 2)}\n`; let existingAgent = ''; try { diff --git a/packages/persona-kit/src/define.test.ts b/packages/persona-kit/src/define.test.ts index d02b81ad..3d2e80b8 100644 --- a/packages/persona-kit/src/define.test.ts +++ b/packages/persona-kit/src/define.test.ts @@ -126,11 +126,18 @@ test('TypedTriggerMap gives per-provider event autocomplete; arbitrary providers { on: 'AgentSessionEvent.prompted' }, { on: 'AppUserNotification.issueCommentMention' } ], - slack: [{ on: 'message.created', maxConcurrency: 1 }], + slack: [ + { + on: 'message.created', + paths: ['/slack/channels/C_REVIEW/**'], + maxConcurrency: 1 + } + ], customProvider: [{ on: 'custom.event' }] }; assert.equal(triggers.github?.[1]?.on, 'off_registry.github_event'); assert.equal(triggers.slack?.[0]?.maxConcurrency, 1); + assert.deepEqual(triggers.slack?.[0]?.paths, ['/slack/channels/C_REVIEW/**']); assert.equal(triggers.customProvider?.[0]?.on, 'custom.event'); }); diff --git a/packages/persona-kit/src/define.ts b/packages/persona-kit/src/define.ts index c98aab32..06672e6c 100644 --- a/packages/persona-kit/src/define.ts +++ b/packages/persona-kit/src/define.ts @@ -24,6 +24,7 @@ export interface TypedTrigger

{ on: TriggerNameFor

; match?: string; where?: string; + paths?: string[]; maxConcurrency?: number; } diff --git a/packages/persona-kit/src/emit-schema.test.ts b/packages/persona-kit/src/emit-schema.test.ts index 5081183f..8774d4b2 100644 --- a/packages/persona-kit/src/emit-schema.test.ts +++ b/packages/persona-kit/src/emit-schema.test.ts @@ -159,7 +159,29 @@ test('agent schema exposes launchedBy/triggers/schedules/watch', async () => { assert.equal(watchRule.properties?.paths && watchRule.properties.paths !== true ? watchRule.properties.paths.type : undefined, 'array'); + const absolutePathItems = { + type: 'string', + minLength: 1, + pattern: '^/(?:[^\\r\\n\\u2028\\u2029]*\\S)?$' + }; + const watchPaths = watchRule.properties?.paths; + assert.equal(watchPaths && watchPaths !== true + ? watchPaths.minItems + : undefined, 1); + assert.deepEqual(watchPaths && watchPaths !== true + ? watchPaths.items + : undefined, absolutePathItems); const trigger = definitions.PersonaIntegrationTrigger; + const triggerPaths = trigger.properties?.paths; + assert.equal(triggerPaths && triggerPaths !== true + ? triggerPaths.type + : undefined, 'array'); + assert.equal(triggerPaths && triggerPaths !== true + ? triggerPaths.minItems + : undefined, 1); + assert.deepEqual(triggerPaths && triggerPaths !== true + ? triggerPaths.items + : undefined, absolutePathItems); const maxConcurrency = trigger.properties?.maxConcurrency; assert.equal(maxConcurrency && maxConcurrency !== true ? maxConcurrency.type @@ -168,7 +190,65 @@ test('agent schema exposes launchedBy/triggers/schedules/watch', async () => { ? maxConcurrency.minimum : undefined, 1); - assertSchema({ triggers: { slack: [{ on: 'message.created', maxConcurrency: 1 }] } }, schema, schema, 'agent'); + assertSchema( + { + triggers: { + slack: [ + { + on: 'message.created', + paths: ['/ slack/channels/C_REVIEW/**'], + maxConcurrency: 1 + } + ] + }, + watch: [{ paths: ['/ github/**'], events: ['created'] }] + }, + schema, + schema, + 'agent' + ); + assert.throws( + () => assertSchema({ triggers: { slack: [{ on: 'message.created', paths: [] }] } }, schema, schema, 'agent'), + /agent\.triggers\.slack\[0\]\.paths must have at least 1 item/ + ); + assert.throws( + () => assertSchema({ triggers: { slack: [{ on: 'message.created', paths: ['slack/**'] }] } }, schema, schema, 'agent'), + /agent\.triggers\.slack\[0\]\.paths\[0\] must match/ + ); + assert.throws( + () => assertSchema({ triggers: { slack: [{ on: 'message.created', paths: ['/slack/** '] }] } }, schema, schema, 'agent'), + /agent\.triggers\.slack\[0\]\.paths\[0\] must match/ + ); + assert.throws( + () => assertSchema({ watch: [{ paths: [], events: ['created'] }] }, schema, schema, 'agent'), + /agent\.watch\[0\]\.paths must have at least 1 item/ + ); + assert.throws( + () => assertSchema({ watch: [{ paths: ['watch/**'], events: ['created'] }] }, schema, schema, 'agent'), + /agent\.watch\[0\]\.paths\[0\] must match/ + ); + assert.throws( + () => assertSchema({ watch: [{ paths: [' /watch/**'], events: ['created'] }] }, schema, schema, 'agent'), + /agent\.watch\[0\]\.paths\[0\] must match/ + ); + assert.throws( + () => assertSchema({ watch: [{ paths: ['/watch/** '], events: ['created'] }] }, schema, schema, 'agent'), + /agent\.watch\[0\]\.paths\[0\] must match/ + ); + for (const separator of ['\r', '\n', '\u2028', '\u2029']) { + assert.throws( + () => assertSchema({ + triggers: { slack: [{ on: 'message.created', paths: [`/slack/${separator}channels/**`] }] } + }, schema, schema, 'agent'), + /agent\.triggers\.slack\[0\]\.paths\[0\] must match/ + ); + assert.throws( + () => assertSchema({ + watch: [{ paths: [`/github/${separator}issues/**`], events: ['created'] }] + }, schema, schema, 'agent'), + /agent\.watch\[0\]\.paths\[0\] must match/ + ); + } assert.throws( () => assertSchema({ triggers: { slack: [{ on: 'message.created', maxConcurrency: 0 }] } }, schema, schema, 'agent'), /agent\.triggers\.slack\[0\]\.maxConcurrency must be >= 1/ @@ -190,6 +270,9 @@ type SchemaNode = Record & { const?: unknown; type?: string | string[]; minimum?: number; + minItems?: number; + minLength?: number; + pattern?: string; properties?: Record; additionalProperties?: SchemaNode | boolean; required?: string[]; @@ -232,6 +315,12 @@ function assertSchema(value: unknown, schema: SchemaNode, root: SchemaNode, path if (typeof schema.minimum === 'number' && typeof value === 'number' && value < schema.minimum) { throw new Error(`${path} must be >= ${schema.minimum}`); } + if (typeof schema.minLength === 'number' && typeof value === 'string' && value.length < schema.minLength) { + throw new Error(`${path} must have at least ${schema.minLength} character(s)`); + } + if (schema.pattern && typeof value === 'string' && !new RegExp(schema.pattern).test(value)) { + throw new Error(`${path} must match ${schema.pattern}`); + } if (schema.type === 'object' || schema.properties || schema.additionalProperties || schema.required) { if (!isObject(value)) { throw new Error(`${path} must be an object`); @@ -256,6 +345,9 @@ function assertSchema(value: unknown, schema: SchemaNode, root: SchemaNode, path if (!Array.isArray(value)) { throw new Error(`${path} must be an array`); } + if (typeof schema.minItems === 'number' && value.length < schema.minItems) { + throw new Error(`${path} must have at least ${schema.minItems} item(s)`); + } if (schema.items) { value.forEach((item, index) => assertSchema(item, schema.items as SchemaNode, root, `${path}[${index}]`) diff --git a/packages/persona-kit/src/parse.test.ts b/packages/persona-kit/src/parse.test.ts index e0decdde..13897f93 100644 --- a/packages/persona-kit/src/parse.test.ts +++ b/packages/persona-kit/src/parse.test.ts @@ -774,17 +774,26 @@ test('parseAgentSpec validates launchedBy plus provider-keyed triggers, schedule triggers: { github: [ { on: 'pull_request.opened' }, - { on: 'issue_comment.created', match: '@mention', maxConcurrency: 1 } + { + on: 'issue_comment.created', + match: '@mention', + paths: ['/github/repos/AgentWorkforce/workforce/issues/**'], + maxConcurrency: 1 + } ], - slack: [{ on: 'app_mention' }] + slack: [{ on: 'app_mention', paths: ['/slack/channels/C_REVIEW/**'] }] }, schedules: [{ name: 'nightly', cron: '0 2 * * *', tz: 'UTC' }], watch: [{ paths: ['/github/x.json'], events: ['created'] }] }); assert.equal(agent.triggers?.github.length, 2); assert.equal(agent.triggers?.github[1].match, '@mention'); + assert.deepEqual(agent.triggers?.github[1].paths, [ + '/github/repos/AgentWorkforce/workforce/issues/**' + ]); assert.equal(agent.triggers?.github[1].maxConcurrency, 1); assert.equal(agent.triggers?.slack[0].on, 'app_mention'); + assert.deepEqual(agent.triggers?.slack[0].paths, ['/slack/channels/C_REVIEW/**']); assert.equal(agent.schedules?.[0].name, 'nightly'); assert.equal(agent.watch?.[0].paths[0], '/github/x.json'); assert.equal(agent.launchedBy, 'team-dispatcher'); @@ -837,6 +846,38 @@ test('parseAgentSpec rejects malformed triggers maps with precise field paths', () => parseAgentSpec({ triggers: { github: [{ on: 'issue.opened', where: 1 }] } }), /triggers\.github\[0\]\.where must be a non-empty string if provided/ ); + assert.throws( + () => parseAgentSpec({ triggers: { github: [{ on: 'issue.opened', paths: '/*' }] } }), + /triggers\.github\[0\]\.paths must be a non-empty array/ + ); + assert.throws( + () => parseAgentSpec({ triggers: { github: [{ on: 'issue.opened', paths: [] }] } }), + /triggers\.github\[0\]\.paths must be a non-empty array/ + ); + assert.throws( + () => parseAgentSpec({ triggers: { github: [{ on: 'issue.opened', paths: [''] }] } }), + /triggers\.github\[0\]\.paths\[0\] must be a non-empty string/ + ); + assert.throws( + () => parseAgentSpec({ triggers: { github: [{ on: 'issue.opened', paths: ['/github/** '] }] } }), + /triggers\.github\[0\]\.paths\[0\] must not have leading or trailing whitespace/ + ); + assert.throws( + () => parseAgentSpec({ triggers: { github: [{ on: 'issue.opened', paths: ['github/**'] }] } }), + /triggers\.github\[0\]\.paths\[0\] must start with \// + ); + const triggerWithInternalSpace = parseAgentSpec({ + triggers: { github: [{ on: 'issue.opened', paths: ['/ github/**'] }] } + }); + assert.deepEqual(triggerWithInternalSpace.triggers?.github[0].paths, ['/ github/**']); + for (const separator of ['\r', '\n', '\u2028', '\u2029']) { + assert.throws( + () => parseAgentSpec({ + triggers: { github: [{ on: 'issue.opened', paths: [`/github/${separator}issues/**`] }] } + }), + /triggers\.github\[0\]\.paths\[0\] must not contain line separators/ + ); + } // An empty agent (no listeners) parses to {}; the deploy CLI enforces "at least one". assert.deepEqual(parseAgentSpec({}), {}); }); @@ -1038,12 +1079,23 @@ test('parseWatch rejects malformed relayfile watch rules with precise field path assert.throws(() => parseWatch([{ events: ['created'] }], 'watch'), /watch\[0\]\.paths must be a non-empty array/); assert.throws(() => parseWatch([{ paths: [], events: ['created'] }], 'watch'), /watch\[0\]\.paths must be a non-empty array/); assert.throws(() => parseWatch([{ paths: [42], events: ['created'] }], 'watch'), /watch\[0\]\.paths\[0\] must be a non-empty string/); + assert.throws(() => parseWatch([{ paths: ['/x '], events: ['created'] }], 'watch'), /watch\[0\]\.paths\[0\] must not have leading or trailing whitespace/); assert.throws(() => parseWatch([{ paths: ['relative/**'], events: ['created'] }], 'watch'), /watch\[0\]\.paths\[0\] must start with \//); assert.throws(() => parseWatch([{ paths: ['/x'] }], 'watch'), /watch\[0\]\.events must be a non-empty array/); assert.throws(() => parseWatch([{ paths: ['/x'], events: [] }], 'watch'), /watch\[0\]\.events must be a non-empty array/); assert.throws(() => parseWatch([{ paths: ['/x'], events: ['changed'] }], 'watch'), /watch\[0\]\.events\[0\] must be one of: created, updated, deleted/); assert.throws(() => parseWatch([{ paths: ['/x'], events: ['created'], debounceMs: -1 }], 'watch'), /watch\[0\]\.debounceMs must be a non-negative number/); assert.throws(() => parseWatch([{ paths: ['/x'], events: ['created'], match: '' }], 'watch'), /watch\[0\]\.match must be a non-empty string/); + assert.deepEqual( + parseWatch([{ paths: ['/ github/**'], events: ['created'] }], 'watch')?.[0].paths, + ['/ github/**'] + ); + for (const separator of ['\r', '\n', '\u2028', '\u2029']) { + assert.throws( + () => parseWatch([{ paths: [`/github/${separator}issues/**`], events: ['created'] }], 'watch'), + /watch\[0\]\.paths\[0\] must not contain line separators/ + ); + } }); test('parsePersonaSpec round-trips mount.enabled=false (watch now lives on the agent)', () => { diff --git a/packages/persona-kit/src/parse.ts b/packages/persona-kit/src/parse.ts index 66d56e41..112af8bd 100644 --- a/packages/persona-kit/src/parse.ts +++ b/packages/persona-kit/src/parse.ts @@ -544,6 +544,28 @@ function assertCronExpression(value: string, context: string): void { } } +function parseAbsolutePathList(value: unknown, context: string): string[] { + if (!Array.isArray(value) || value.length === 0) { + throw new Error(`${context} must be a non-empty array`); + } + return value.map((path, pathIdx) => { + const pathContext = `${context}[${pathIdx}]`; + if (typeof path !== 'string' || !path.trim()) { + throw new Error(`${pathContext} must be a non-empty string`); + } + if (path !== path.trim()) { + throw new Error(`${pathContext} must not have leading or trailing whitespace`); + } + if (/[\r\n\u2028\u2029]/u.test(path)) { + throw new Error(`${pathContext} must not contain line separators`); + } + if (!path.startsWith('/')) { + throw new Error(`${pathContext} must start with /`); + } + return path; + }); +} + export function parseIntegrationTrigger( value: unknown, context: string @@ -551,7 +573,7 @@ export function parseIntegrationTrigger( if (!isObject(value)) { throw new Error(`${context} must be an object`); } - const { on, match, where, maxConcurrency } = value; + const { on, match, where, paths, maxConcurrency } = value; if (typeof on !== 'string' || !on.trim()) { throw new Error(`${context}.on must be a non-empty string`); } @@ -561,6 +583,9 @@ export function parseIntegrationTrigger( if (where !== undefined && (typeof where !== 'string' || !where.trim())) { throw new Error(`${context}.where must be a non-empty string if provided`); } + const parsedPaths = paths === undefined + ? undefined + : parseAbsolutePathList(paths, `${context}.paths`); // Intentionally lenient: Cloud derives this as an optional backpressure hint, // so invalid values mean "unset" rather than a parse failure. const parsedMaxConcurrency = @@ -573,6 +598,7 @@ export function parseIntegrationTrigger( on, ...(typeof match === 'string' ? { match } : {}), ...(typeof where === 'string' ? { where } : {}), + ...(parsedPaths ? { paths: parsedPaths } : {}), ...(parsedMaxConcurrency !== undefined ? { maxConcurrency: parsedMaxConcurrency } : {}) @@ -803,19 +829,7 @@ export function parseWatch(value: unknown, context: string): WatchRule[] | undef throw new Error(`${entryContext} must be an object`); } const { paths, events, debounceMs, match } = entry; - if (!Array.isArray(paths) || paths.length === 0) { - throw new Error(`${entryContext}.paths must be a non-empty array`); - } - const parsedPaths = paths.map((path, pathIdx) => { - const pathContext = `${entryContext}.paths[${pathIdx}]`; - if (typeof path !== 'string' || !path.trim()) { - throw new Error(`${pathContext} must be a non-empty string`); - } - if (!path.startsWith('/')) { - throw new Error(`${pathContext} must start with /`); - } - return path; - }); + const parsedPaths = parseAbsolutePathList(paths, `${entryContext}.paths`); if (!Array.isArray(events) || events.length === 0) { throw new Error(`${entryContext}.events must be a non-empty array`); diff --git a/packages/persona-kit/src/types.ts b/packages/persona-kit/src/types.ts index c773639f..e8d46550 100644 --- a/packages/persona-kit/src/types.ts +++ b/packages/persona-kit/src/types.ts @@ -194,12 +194,18 @@ export type McpServerSpec = * { on: "pull_request.opened" } * { on: "pull_request_review_comment.created", match: "@mention" } * { on: "check_run.completed", where: "conclusion=failure" } + * { on: "message.created", paths: ["/slack/channels/C_REVIEW/**"] } * { on: "message.created", maxConcurrency: 1 } */ export interface PersonaIntegrationTrigger { on: string; match?: string; where?: string; + /** + * Optional Relayfile path globs used to scope wake routing before an agent is + * provisioned. Paths are absolute (leading `/`) and provider-specific. + */ + paths?: string[]; /** * Optional delivery backpressure hint for this trigger. Positive integers are * preserved by the parser; invalid values are treated as unset so older or diff --git a/packages/runtime/src/define-agent.test.ts b/packages/runtime/src/define-agent.test.ts index 79f124f2..c4b10b42 100644 --- a/packages/runtime/src/define-agent.test.ts +++ b/packages/runtime/src/define-agent.test.ts @@ -11,8 +11,15 @@ test('defineAgent brands the object and wraps the handler', () => { const agent = defineAgent({ launchedBy: 'team-dispatcher', triggers: { - github: [{ on: 'pull_request.opened' }, { on: 'issue_comment.created', match: '@mention' }], - slack: [{ on: 'app_mention' }] + github: [ + { on: 'pull_request.opened' }, + { + on: 'issue_comment.created', + match: '@mention', + paths: ['/github/repos/AgentWorkforce/workforce/issues/**'] + } + ], + slack: [{ on: 'app_mention', paths: ['/slack/channels/C_REVIEW/**'] }] }, schedules: [{ name: 'nightly', cron: '0 2 * * *', tz: 'UTC' }], handler: async () => { @@ -23,7 +30,11 @@ test('defineAgent brands the object and wraps the handler', () => { assert.equal(isWorkforceAgent(agent), true); assert.equal(isWorkforceHandler(agent.handler), true); assert.equal(agent.triggers?.github?.length, 2); + assert.deepEqual(agent.triggers?.github?.[1]?.paths, [ + '/github/repos/AgentWorkforce/workforce/issues/**' + ]); assert.equal(agent.triggers?.slack?.[0]?.on, 'app_mention'); + assert.deepEqual(agent.triggers?.slack?.[0]?.paths, ['/slack/channels/C_REVIEW/**']); assert.equal(agent.schedules?.[0]?.name, 'nightly'); assert.equal(agent.launchedBy, 'team-dispatcher'); // __workforceAgent is non-enumerable so the listener declarations serialize clean.