diff --git a/src/agents/definitions/backlog-manager.yaml b/src/agents/definitions/backlog-manager.yaml index 3ba81f1f..f1d0ada4 100644 --- a/src/agents/definitions/backlog-manager.yaml +++ b/src/agents/definitions/backlog-manager.yaml @@ -42,7 +42,7 @@ triggers: - event: internal:auto-chain label: Auto-chain after Splitting description: When splitting completes on a card with the auto label, immediately chain to backlog manager - defaultEnabled: true + defaultEnabled: false contextPipeline: [pipelineSnapshot] strategies: {} diff --git a/src/agents/definitions/implementation.yaml b/src/agents/definitions/implementation.yaml index 0e7bee91..912f99d6 100644 --- a/src/agents/definitions/implementation.yaml +++ b/src/agents/definitions/implementation.yaml @@ -28,7 +28,7 @@ triggers: - event: pm:status-changed label: Status Changed to Todo description: Trigger when work item status changes to Todo - defaultEnabled: true + defaultEnabled: false parameters: - name: targetStatus type: select @@ -39,7 +39,7 @@ triggers: - event: pm:label-added label: Ready to Process Label description: Trigger when Ready to Process label added to a card in the Todo list - defaultEnabled: true + defaultEnabled: false parameters: - name: listKey type: select diff --git a/src/agents/definitions/planning.yaml b/src/agents/definitions/planning.yaml index 950a510a..7ea28d07 100644 --- a/src/agents/definitions/planning.yaml +++ b/src/agents/definitions/planning.yaml @@ -25,7 +25,7 @@ triggers: - event: pm:status-changed label: Status Changed to Planning description: Trigger when work item status changes to Planning - defaultEnabled: true + defaultEnabled: false parameters: - name: targetStatus type: select @@ -36,7 +36,7 @@ triggers: - event: pm:label-added label: Ready to Process Label description: Trigger when Ready to Process label added to a card in Planning list - defaultEnabled: true + defaultEnabled: false parameters: - name: listKey type: select @@ -47,7 +47,7 @@ triggers: - event: pm:comment-mention label: Comment @mention description: Trigger when bot is @mentioned in a card/issue comment - defaultEnabled: true + defaultEnabled: false contextPipeline: [directoryListing, contextFiles, squint, workItem] strategies: {} diff --git a/src/agents/definitions/resolve-conflicts.yaml b/src/agents/definitions/resolve-conflicts.yaml index 10e38bff..c7a7591f 100644 --- a/src/agents/definitions/resolve-conflicts.yaml +++ b/src/agents/definitions/resolve-conflicts.yaml @@ -28,7 +28,7 @@ triggers: - event: scm:pr-conflict-detected label: PR Conflict Detected description: Trigger when a PR has merge conflicts with the base branch - defaultEnabled: true + defaultEnabled: false providers: [github] contextPipeline: [prContext, directoryListing, contextFiles, squint, workItem] diff --git a/src/agents/definitions/respond-to-ci.yaml b/src/agents/definitions/respond-to-ci.yaml index 80068950..23d64ae9 100644 --- a/src/agents/definitions/respond-to-ci.yaml +++ b/src/agents/definitions/respond-to-ci.yaml @@ -29,7 +29,7 @@ triggers: - event: scm:check-suite-failure label: Check Suite Failure description: Trigger when CI checks fail - defaultEnabled: true + defaultEnabled: false providers: [github] contextPipeline: [prContext, directoryListing, contextFiles, squint, workItem] diff --git a/src/agents/definitions/respond-to-planning-comment.yaml b/src/agents/definitions/respond-to-planning-comment.yaml index db223d54..b2f1db7a 100644 --- a/src/agents/definitions/respond-to-planning-comment.yaml +++ b/src/agents/definitions/respond-to-planning-comment.yaml @@ -26,7 +26,7 @@ triggers: - event: pm:comment-mention label: Comment @mention description: Trigger when bot is @mentioned in a card/issue comment - defaultEnabled: true + defaultEnabled: false contextPipeline: [directoryListing, contextFiles, squint, workItem] strategies: {} diff --git a/src/agents/definitions/respond-to-pr-comment.yaml b/src/agents/definitions/respond-to-pr-comment.yaml index 7408d28e..b962b80c 100644 --- a/src/agents/definitions/respond-to-pr-comment.yaml +++ b/src/agents/definitions/respond-to-pr-comment.yaml @@ -27,7 +27,7 @@ triggers: - event: scm:pr-comment-mention label: PR Comment @mention description: Trigger when the implementer bot is @mentioned in a PR comment - defaultEnabled: true + defaultEnabled: false providers: [github] contextPipeline: [prContext, prConversation, directoryListing, contextFiles, squint] diff --git a/src/agents/definitions/respond-to-review.yaml b/src/agents/definitions/respond-to-review.yaml index 061187f8..5a768c83 100644 --- a/src/agents/definitions/respond-to-review.yaml +++ b/src/agents/definitions/respond-to-review.yaml @@ -28,7 +28,7 @@ triggers: - event: scm:pr-review-submitted label: PR Review Submitted description: Trigger when a review with changes requested or comments is submitted - defaultEnabled: true + defaultEnabled: false providers: [github] contextPipeline: [prContext, prConversation, directoryListing, contextFiles, squint] diff --git a/src/agents/definitions/review.yaml b/src/agents/definitions/review.yaml index bedb64a5..3bd11888 100644 --- a/src/agents/definitions/review.yaml +++ b/src/agents/definitions/review.yaml @@ -59,13 +59,13 @@ triggers: - event: scm:pr-ready-to-merge label: PR Ready to Merge description: Move work item to DONE when PR is approved and all checks pass - defaultEnabled: true + defaultEnabled: false providers: [github] contextPipeline: [] - event: scm:pr-merged label: PR Merged description: Move work item to MERGED status when PR is merged - defaultEnabled: true + defaultEnabled: false providers: [github] contextPipeline: [] diff --git a/src/agents/definitions/schema.ts b/src/agents/definitions/schema.ts index 9dafd15e..16492314 100644 --- a/src/agents/definitions/schema.ts +++ b/src/agents/definitions/schema.ts @@ -107,7 +107,7 @@ export const SupportedTriggerSchema = z.object({ /** Optional description for help text */ description: z.string().optional(), /** Whether the trigger is enabled by default */ - defaultEnabled: z.boolean().default(true), + defaultEnabled: z.boolean().default(false), /** Configurable parameters for this trigger */ parameters: z.array(TriggerParameterSchema).default([]), /** Provider filter - only applies to these providers (e.g., ['trello']) */ diff --git a/src/agents/definitions/splitting.yaml b/src/agents/definitions/splitting.yaml index 974918c8..e8dfa87e 100644 --- a/src/agents/definitions/splitting.yaml +++ b/src/agents/definitions/splitting.yaml @@ -26,7 +26,7 @@ triggers: - event: pm:status-changed label: Status Changed to Splitting description: Trigger when work item status changes to Splitting - defaultEnabled: true + defaultEnabled: false parameters: - name: targetStatus type: select @@ -37,7 +37,7 @@ triggers: - event: pm:label-added label: Ready to Process Label description: Trigger when Ready to Process label added to a card in Splitting list - defaultEnabled: true + defaultEnabled: false parameters: - name: listKey type: select diff --git a/src/api/routers/_shared/triggerTypes.ts b/src/api/routers/_shared/triggerTypes.ts index 29218544..1b8f1e23 100644 --- a/src/api/routers/_shared/triggerTypes.ts +++ b/src/api/routers/_shared/triggerTypes.ts @@ -82,9 +82,19 @@ export interface ProjectIntegrationsMap { /** * Complete triggers view for a project. * Response type for getProjectTriggersView. + * + * `enabledAgents` — agents that have an explicit agent_configs row (opt-in enabled). + * `availableAgents` — agents that exist in definitions but are NOT yet configured. + * + * The legacy `agents` field equals `enabledAgents` for backwards compatibility. */ export interface ProjectTriggersView { + /** @deprecated Use enabledAgents instead */ agents: AgentTriggersView[]; + /** Agents with an explicit agent_configs row — actively configured for this project */ + enabledAgents: AgentTriggersView[]; + /** Agent types defined in YAML/DB but not yet configured for this project */ + availableAgents: string[]; integrations: ProjectIntegrationsMap; } diff --git a/src/api/routers/agentTriggerConfigs.ts b/src/api/routers/agentTriggerConfigs.ts index 7c94ee5a..65812ccf 100644 --- a/src/api/routers/agentTriggerConfigs.ts +++ b/src/api/routers/agentTriggerConfigs.ts @@ -6,6 +6,7 @@ import type { SupportedTrigger, TriggerParameter, } from '../../agents/definitions/schema.js'; +import { listAgentConfigs } from '../../db/repositories/agentConfigsRepository.js'; import { listAgentDefinitions } from '../../db/repositories/agentDefinitionsRepository.js'; import { deleteTriggerConfig, @@ -204,16 +205,20 @@ export const agentTriggerConfigsRouter = router({ .query(async ({ ctx, input }): Promise => { await verifyProjectOrgAccess(input.projectId, ctx.effectiveOrgId); - // Fetch DB definitions and configs in parallel - const [dbDefinitions, configs, integrations] = await Promise.all([ + // Fetch DB definitions, trigger configs, agent configs (for enabled check), and integrations + const [dbDefinitions, configs, projectAgentConfigs, integrations] = await Promise.all([ listAgentDefinitions().catch((err) => { logger.warn('Failed to fetch agent definitions from DB', { error: err }); return []; }), getTriggerConfigsByProject(input.projectId), + listAgentConfigs({ projectId: input.projectId }), listProjectIntegrations(input.projectId), ]); + // Build set of explicitly enabled agent types for this project + const enabledAgentTypes = new Set(projectAgentConfigs.map((c) => c.agentType)); + // Build a combined list of definitions (DB + YAML) const yamlTypes = getKnownAgentTypes(); const definitions: Array<{ agentType: string; definition: AgentDefinition }> = []; @@ -280,12 +285,12 @@ export const agentTriggerConfigsRouter = router({ }; } - // Build the agents array with merged trigger data - const agents = definitions.map((def) => { - const agentConfigs = configMap.get(def.agentType); + // Build merged trigger data for a definition + function buildAgentTriggersView(def: { agentType: string; definition: AgentDefinition }) { + const agentTriggerConfigs = configMap.get(def.agentType); const triggers: ResolvedTrigger[] = (def.definition.triggers ?? []).map( (trigger: SupportedTrigger) => { - const config = agentConfigs?.get(trigger.event); + const config = agentTriggerConfigs?.get(trigger.event); return { event: trigger.event, label: trigger.label, @@ -301,12 +306,18 @@ export const agentTriggerConfigsRouter = router({ }; }, ); + return { agentType: def.agentType, triggers }; + } - return { - agentType: def.agentType, - triggers, - }; - }); + // Split definitions into enabled (have agent_configs row) and available (no row) + // The debug agent is always shown as enabled (internal infrastructure) + const enabledAgents = definitions + .filter((def) => enabledAgentTypes.has(def.agentType) || def.agentType === 'debug') + .map(buildAgentTriggersView); + + const availableAgents = definitions + .filter((def) => !enabledAgentTypes.has(def.agentType) && def.agentType !== 'debug') + .map((def) => def.agentType); // Build integrations map with single pass const integrationsMap = { @@ -321,7 +332,9 @@ export const agentTriggerConfigsRouter = router({ } return { - agents, + agents: enabledAgents, // backwards compat: same as enabledAgents + enabledAgents, + availableAgents, integrations: integrationsMap, }; }), diff --git a/src/api/routers/runs.ts b/src/api/routers/runs.ts index 7f70ca2e..374ca937 100644 --- a/src/api/routers/runs.ts +++ b/src/api/routers/runs.ts @@ -1,6 +1,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { loadProjectConfigById } from '../../config/provider.js'; +import { isAgentEnabledForProject } from '../../db/repositories/agentConfigsRepository.js'; import { DEFAULT_STALE_RUN_THRESHOLD_MS, cancelRunById, @@ -274,6 +275,15 @@ export const runsRouter = router({ }); } + // Check agent is explicitly enabled for this project + const agentEnabled = await isAgentEnabledForProject(input.projectId, input.agentType); + if (!agentEnabled) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Agent '${input.agentType}' is not enabled for this project. Add an agent config in Project Settings > Agent Configs to enable it.`, + }); + } + if (useQueue) { const { submitDashboardJob } = await import('../../queue/client.js'); await submitDashboardJob({ diff --git a/src/cli/dashboard/agents/list.ts b/src/cli/dashboard/agents/list.ts index 71422313..b8802229 100644 --- a/src/cli/dashboard/agents/list.ts +++ b/src/cli/dashboard/agents/list.ts @@ -2,7 +2,8 @@ import { Flags } from '@oclif/core'; import { DashboardCommand } from '../_shared/base.js'; export default class AgentsList extends DashboardCommand { - static override description = 'List agent configurations for a project.'; + static override description = + 'List enabled agent configurations for a project. Only agents with an explicit config row are shown (opt-in required).'; static override flags = { ...DashboardCommand.baseFlags, @@ -22,6 +23,11 @@ export default class AgentsList extends DashboardCommand { return; } + if (configs.length === 0) { + this.log('No agents enabled for this project. Use `cascade agents create` to enable one.'); + return; + } + this.outputTable(configs as unknown as Record[], [ { key: 'id', header: 'ID' }, { key: 'agentType', header: 'Agent Type' }, diff --git a/src/db/repositories/agentConfigsRepository.ts b/src/db/repositories/agentConfigsRepository.ts index 85448c3d..eaaf7bf7 100644 --- a/src/db/repositories/agentConfigsRepository.ts +++ b/src/db/repositories/agentConfigsRepository.ts @@ -116,6 +116,56 @@ export async function getAgentConfigPrompts( return result; } +/** + * Check whether an agent is explicitly enabled for a project. + * An agent is enabled if and only if it has a row in `agent_configs` for that project. + * The `debug` agent is always considered enabled (internal infrastructure). + * + * Results are cached for 5 seconds to avoid repeated DB queries on + * sequential webhook batches. + */ +const AGENT_ENABLED_TTL_MS = 5_000; +const agentEnabledCache = new Map(); + +export async function isAgentEnabledForProject( + projectId: string, + agentType: string, +): Promise { + // Debug agent is always enabled — internal infrastructure agent + if (agentType === 'debug') { + return true; + } + + const cacheKey = `${projectId}:${agentType}`; + const cached = agentEnabledCache.get(cacheKey); + if (cached && Date.now() < cached.expiresAt) { + return cached.value; + } + + const db = getDb(); + + const [row] = await db + .select({ id: agentConfigs.id }) + .from(agentConfigs) + .where(and(eq(agentConfigs.projectId, projectId), eq(agentConfigs.agentType, agentType))) + .limit(1); + + const result = row !== undefined; + agentEnabledCache.set(cacheKey, { + value: result, + expiresAt: Date.now() + AGENT_ENABLED_TTL_MS, + }); + return result; +} + +/** + * Clear the agent-enabled cache (for testing only). + * This allows integration tests to seed agent configs and see them without waiting for TTL expiry. + */ +export function clearAgentEnabledCache(): void { + agentEnabledCache.clear(); +} + /** * Resolve max_concurrency for a (projectId, agentType) pair. * Returns null if no project-scoped config with max_concurrency is found (= no limit). diff --git a/src/triggers/config-resolver.ts b/src/triggers/config-resolver.ts index 94397113..df207f24 100644 --- a/src/triggers/config-resolver.ts +++ b/src/triggers/config-resolver.ts @@ -38,6 +38,7 @@ import { resolveAgentDefinition } from '../agents/definitions/index.js'; import type { SupportedTrigger } from '../agents/definitions/schema.js'; +import { isAgentEnabledForProject } from '../db/repositories/agentConfigsRepository.js'; import { type AgentTriggerConfig, getTriggerConfig, @@ -77,6 +78,12 @@ export async function resolveTriggerConfigs( projectId: string, agentType: string, ): Promise { + // Gate on agent config existence — agent must be explicitly enabled + const enabled = await isAgentEnabledForProject(projectId, agentType); + if (!enabled) { + return []; + } + // Get definition triggers const definition = await resolveAgentDefinition(agentType); if (!definition) { @@ -111,6 +118,12 @@ export async function isTriggerEnabled( agentType: string, triggerEvent: string, ): Promise { + // Gate on agent config existence — agent must be explicitly enabled + const agentEnabled = await isAgentEnabledForProject(projectId, agentType); + if (!agentEnabled) { + return false; + } + // First check DB override const dbConfig = await getTriggerConfig(projectId, agentType, triggerEvent); if (dbConfig) { @@ -140,6 +153,12 @@ export async function getTriggerParameters( agentType: string, triggerEvent: string, ): Promise> { + // Gate on agent config existence — agent must be explicitly enabled + const agentEnabled = await isAgentEnabledForProject(projectId, agentType); + if (!agentEnabled) { + return {}; + } + const definition = await resolveAgentDefinition(agentType); if (!definition) { return {}; @@ -176,6 +195,12 @@ export async function getResolvedTriggerConfig( agentType: string, triggerEvent: string, ): Promise { + // Gate on agent config existence — agent must be explicitly enabled + const agentEnabled = await isAgentEnabledForProject(projectId, agentType); + if (!agentEnabled) { + return null; + } + const definition = await resolveAgentDefinition(agentType); if (!definition) { return null; diff --git a/src/triggers/shared/manual-runner.ts b/src/triggers/shared/manual-runner.ts index e3862763..715507a0 100644 --- a/src/triggers/shared/manual-runner.ts +++ b/src/triggers/shared/manual-runner.ts @@ -1,4 +1,5 @@ import { runAgent } from '../../agents/registry.js'; +import { isAgentEnabledForProject } from '../../db/repositories/agentConfigsRepository.js'; import { getRunById } from '../../db/repositories/runsRepository.js'; import { withPMCredentials } from '../../pm/context.js'; import { createPMProvider, pmRegistry, withPMProvider } from '../../pm/index.js'; @@ -79,6 +80,14 @@ export async function triggerManualRun( ); } + // Check agent is explicitly enabled for this project + const agentEnabled = await isAgentEnabledForProject(input.projectId, input.agentType); + if (!agentEnabled) { + throw new Error( + `Agent '${input.agentType}' is not enabled for project '${input.projectId}'. Add an agent config in Project Settings > Agent Configs to enable it.`, + ); + } + // Pre-flight integration validation const validation = await validateIntegrations(input.projectId, input.agentType); if (!validation.valid) { diff --git a/tests/integration/github-personas.test.ts b/tests/integration/github-personas.test.ts index 19fe6451..c4cf9639 100644 --- a/tests/integration/github-personas.test.ts +++ b/tests/integration/github-personas.test.ts @@ -22,7 +22,13 @@ import { ReviewRequestedTrigger } from '../../src/triggers/github/review-request import type { TriggerContext } from '../../src/types/index.js'; import { assertFound } from './helpers/assert.js'; import { truncateAll } from './helpers/db.js'; -import { seedIntegration, seedOrg, seedProject, seedTriggerConfig } from './helpers/seed.js'; +import { + seedAgentConfig, + seedIntegration, + seedOrg, + seedProject, + seedTriggerConfig, +} from './helpers/seed.js'; // ============================================================================ // Helpers @@ -229,6 +235,13 @@ describe('GitHub Dual-Persona System (integration)', () => { config: {}, triggers: { prReviewSubmitted: true }, }); + // Agent must be explicitly enabled for the trigger to fire + await seedAgentConfig({ agentType: 'respond-to-review' }); + await seedTriggerConfig({ + agentType: 'respond-to-review', + triggerEvent: 'scm:pr-review-submitted', + enabled: true, + }); const project = await findProjectByRepoFromDb('owner/repo'); expect(project).toBeDefined(); @@ -350,6 +363,8 @@ describe('GitHub Dual-Persona System (integration)', () => { provider: 'github', config: {}, }); + // Agent must be explicitly enabled for the trigger to fire + await seedAgentConfig({ agentType: 'review' }); await seedTriggerConfig({ agentType: 'review', triggerEvent: 'scm:review-requested', diff --git a/tests/integration/helpers/db.ts b/tests/integration/helpers/db.ts index df3720ad..f1c605e1 100644 --- a/tests/integration/helpers/db.ts +++ b/tests/integration/helpers/db.ts @@ -4,6 +4,7 @@ import net from 'node:net'; import path from 'node:path'; import { migrate } from 'drizzle-orm/node-postgres/migrator'; import { _setTestDb, closeDb, getDb } from '../../../src/db/client.js'; +import { clearAgentEnabledCache } from '../../../src/db/repositories/agentConfigsRepository.js'; function checkPortReachable(host: string, port: number, timeoutMs = 500): Promise { return new Promise((resolve) => { @@ -98,6 +99,7 @@ export async function runMigrations() { /** * Truncates all application tables in dependency order. * Call in `beforeEach` to isolate tests. + * Also clears in-memory repository caches so tests see fresh DB state. */ export async function truncateAll() { const db = getDb(); @@ -121,6 +123,8 @@ export async function truncateAll() { organizations CASCADE `); + // Clear in-memory caches so subsequent tests see fresh DB state + clearAgentEnabledCache(); } /** diff --git a/tests/integration/pm-provider-switching.test.ts b/tests/integration/pm-provider-switching.test.ts index c866a576..3d8f8fcf 100644 --- a/tests/integration/pm-provider-switching.test.ts +++ b/tests/integration/pm-provider-switching.test.ts @@ -21,7 +21,13 @@ import { TrelloStatusChangedTodoTrigger } from '../../src/triggers/trello/status import type { TriggerContext } from '../../src/types/index.js'; import { assertFound } from './helpers/assert.js'; import { truncateAll } from './helpers/db.js'; -import { seedIntegration, seedOrg, seedProject } from './helpers/seed.js'; +import { + seedAgentConfig, + seedIntegration, + seedOrg, + seedProject, + seedTriggerConfig, +} from './helpers/seed.js'; // ============================================================================ // Helpers @@ -226,6 +232,13 @@ describe('PM Provider Switching (integration)', () => { labels: {}, }, }); + // Agent must be explicitly enabled for the trigger to fire + await seedAgentConfig({ agentType: 'implementation' }); + await seedTriggerConfig({ + agentType: 'implementation', + triggerEvent: 'pm:status-changed', + enabled: true, + }); const project = await findProjectByBoardIdFromDb('board-123'); expect(project).toBeDefined(); @@ -252,6 +265,13 @@ describe('PM Provider Switching (integration)', () => { statuses: { todo: 'To Do', planning: 'In Planning', splitting: 'Splitting' }, }, }); + // Agent must be explicitly enabled for the trigger to fire + await seedAgentConfig({ agentType: 'implementation' }); + await seedTriggerConfig({ + agentType: 'implementation', + triggerEvent: 'pm:status-changed', + enabled: true, + }); const project = await findProjectByJiraProjectKeyFromDb('IMPL'); expect(project).toBeDefined(); @@ -279,6 +299,13 @@ describe('PM Provider Switching (integration)', () => { statuses: { todo: 'To Do', planning: 'In Planning', splitting: 'Splitting' }, }, }); + // Agent must be explicitly enabled for the trigger to fire + await seedAgentConfig({ agentType: 'planning' }); + await seedTriggerConfig({ + agentType: 'planning', + triggerEvent: 'pm:status-changed', + enabled: true, + }); const project = await findProjectByJiraProjectKeyFromDb('PLAN'); expect(project).toBeDefined(); diff --git a/tests/integration/trigger-registry.test.ts b/tests/integration/trigger-registry.test.ts index 5e3405fb..09bc2a56 100644 --- a/tests/integration/trigger-registry.test.ts +++ b/tests/integration/trigger-registry.test.ts @@ -20,7 +20,13 @@ import { import type { TriggerContext } from '../../src/types/index.js'; import { assertFound } from './helpers/assert.js'; import { truncateAll } from './helpers/db.js'; -import { seedIntegration, seedOrg, seedProject, seedTriggerConfig } from './helpers/seed.js'; +import { + seedAgentConfig, + seedIntegration, + seedOrg, + seedProject, + seedTriggerConfig, +} from './helpers/seed.js'; // ============================================================================ // Helpers @@ -291,6 +297,14 @@ describe('Trigger Registry (integration)', () => { labels: {}, }, }); + // Agent must be explicitly enabled for the trigger to fire + await seedAgentConfig({ agentType: 'implementation' }); + // Seed trigger config to enable the trigger + await seedTriggerConfig({ + agentType: 'implementation', + triggerEvent: 'pm:status-changed', + enabled: true, + }); const project = await findProjectByBoardIdFromDb('board-123'); @@ -323,6 +337,14 @@ describe('Trigger Registry (integration)', () => { labels: {}, }, }); + // Agent must be explicitly enabled for the trigger to fire + await seedAgentConfig({ agentType: 'splitting' }); + // Seed trigger config to enable the trigger + await seedTriggerConfig({ + agentType: 'splitting', + triggerEvent: 'pm:status-changed', + enabled: true, + }); const project = await findProjectByBoardIdFromDb('board-123'); @@ -353,6 +375,14 @@ describe('Trigger Registry (integration)', () => { labels: {}, }, }); + // Agent must be explicitly enabled for the trigger to fire + await seedAgentConfig({ agentType: 'planning' }); + // Seed trigger config to enable the trigger + await seedTriggerConfig({ + agentType: 'planning', + triggerEvent: 'pm:status-changed', + enabled: true, + }); const project = await findProjectByBoardIdFromDb('board-123'); @@ -525,6 +555,20 @@ describe('Trigger Registry (integration)', () => { labels: { readyToProcess: 'Ready to Process' }, }, }); + // Agents must be explicitly enabled for triggers to fire + await seedAgentConfig({ agentType: 'implementation' }); + await seedAgentConfig({ agentType: 'splitting' }); + // Seed trigger configs to enable the triggers + await seedTriggerConfig({ + agentType: 'implementation', + triggerEvent: 'pm:status-changed', + enabled: true, + }); + await seedTriggerConfig({ + agentType: 'splitting', + triggerEvent: 'pm:status-changed', + enabled: true, + }); const registry = createTriggerRegistry(); registry.register(TrelloStatusChangedSplittingTrigger); diff --git a/tests/unit/agents/definitions/loader.test.ts b/tests/unit/agents/definitions/loader.test.ts index 807d1655..f5c747cb 100644 --- a/tests/unit/agents/definitions/loader.test.ts +++ b/tests/unit/agents/definitions/loader.test.ts @@ -279,10 +279,10 @@ describe('YAML agent definitions loader', () => { } }); - it('backlog-manager internal:auto-chain trigger is defaultEnabled: true', () => { + it('backlog-manager internal:auto-chain trigger is defaultEnabled: false (all triggers off by default)', () => { const def = loadAgentDefinition('backlog-manager'); const autoChainTrigger = def.triggers.find((t) => t.event === 'internal:auto-chain'); - expect(autoChainTrigger?.defaultEnabled).toBe(true); + expect(autoChainTrigger?.defaultEnabled).toBe(false); }); it('backlog-manager requires only pm integration', () => { diff --git a/tests/unit/api/routers/agentTriggerConfigs.getProjectTriggersView.test.ts b/tests/unit/api/routers/agentTriggerConfigs.getProjectTriggersView.test.ts index 07a279d8..653414d1 100644 --- a/tests/unit/api/routers/agentTriggerConfigs.getProjectTriggersView.test.ts +++ b/tests/unit/api/routers/agentTriggerConfigs.getProjectTriggersView.test.ts @@ -11,6 +11,7 @@ const mockGetTriggerConfigsByProject = vi.fn(); const mockListProjectIntegrations = vi.fn(); const mockGetKnownAgentTypes = vi.fn(); const mockLoadAgentDefinition = vi.fn(); +const mockListAgentConfigs = vi.fn(); vi.mock('../../../../src/db/repositories/agentDefinitionsRepository.js', () => ({ listAgentDefinitions: (...args: unknown[]) => mockListAgentDefinitions(...args), @@ -30,6 +31,11 @@ vi.mock('../../../../src/db/repositories/settingsRepository.js', () => ({ listProjectIntegrations: (...args: unknown[]) => mockListProjectIntegrations(...args), })); +vi.mock('../../../../src/db/repositories/agentConfigsRepository.js', () => ({ + listAgentConfigs: (...args: unknown[]) => mockListAgentConfigs(...args), + isAgentEnabledForProject: vi.fn().mockResolvedValue(true), +})); + vi.mock('../../../../src/agents/definitions/loader.js', () => ({ getKnownAgentTypes: (...args: unknown[]) => mockGetKnownAgentTypes(...args), loadAgentDefinition: (...args: unknown[]) => mockLoadAgentDefinition(...args), @@ -86,6 +92,8 @@ describe('agentTriggerConfigsRouter — getProjectTriggersView', () => { mockListAgentDefinitions.mockResolvedValue([]); mockGetKnownAgentTypes.mockReturnValue([]); mockLoadAgentDefinition.mockReturnValue(makeAgentDefinition()); + // Default: no agent configs (all agents are unconfigured / available) + mockListAgentConfigs.mockResolvedValue([]); }); it('throws UNAUTHORIZED when not authenticated', async () => { @@ -95,17 +103,32 @@ describe('agentTriggerConfigsRouter — getProjectTriggersView', () => { ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); }); - it('returns empty agents and null integrations when nothing is configured', async () => { + it('returns empty enabledAgents and null integrations when nothing is configured', async () => { const caller = createCaller(mockCtx); const result = await caller.getProjectTriggersView({ projectId: 'test-project' }); - expect(result.agents).toEqual([]); + expect(result.enabledAgents).toEqual([]); + expect(result.agents).toEqual([]); // backwards compat alias expect(result.integrations).toEqual({ pm: null, scm: null }); }); + it('returns availableAgents for unconfigured agent types', async () => { + const definition = makeAgentDefinition(); + mockListAgentDefinitions.mockResolvedValue([{ agentType: 'implementation', definition }]); + // No agent_configs row → implementation should be in availableAgents, not enabledAgents + + const caller = createCaller(mockCtx); + const result = await caller.getProjectTriggersView({ projectId: 'test-project' }); + + expect(result.enabledAgents).toHaveLength(0); + expect(result.availableAgents).toContain('implementation'); + }); + it('merges DB definitions with project trigger configs', async () => { const definition = makeAgentDefinition(); mockListAgentDefinitions.mockResolvedValue([{ agentType: 'implementation', definition }]); + // Agent has an agent_configs row → it is enabled + mockListAgentConfigs.mockResolvedValue([{ agentType: 'implementation', id: 1 }]); mockGetTriggerConfigsByProject.mockResolvedValue([ { agentType: 'implementation', @@ -118,16 +141,18 @@ describe('agentTriggerConfigsRouter — getProjectTriggersView', () => { const caller = createCaller(mockCtx); const result = await caller.getProjectTriggersView({ projectId: 'test-project' }); - expect(result.agents).toHaveLength(1); + expect(result.enabledAgents).toHaveLength(1); + expect(result.agents).toHaveLength(1); // backwards compat expect(result.agents[0].agentType).toBe('implementation'); expect(result.agents[0].triggers[0].event).toBe('pm:status-changed'); expect(result.agents[0].triggers[0].enabled).toBe(true); expect(result.agents[0].triggers[0].isCustomized).toBe(true); }); - it('uses defaultEnabled when no config exists (isCustomized=false)', async () => { + it('uses defaultEnabled when no trigger config exists (isCustomized=false)', async () => { const definition = makeAgentDefinition(); mockListAgentDefinitions.mockResolvedValue([{ agentType: 'implementation', definition }]); + mockListAgentConfigs.mockResolvedValue([{ agentType: 'implementation', id: 1 }]); // No trigger configs const caller = createCaller(mockCtx); @@ -164,6 +189,7 @@ describe('agentTriggerConfigsRouter — getProjectTriggersView', () => { mockListAgentDefinitions.mockResolvedValue([ { agentType: 'review', definition: definitionWithParams }, ]); + mockListAgentConfigs.mockResolvedValue([{ agentType: 'review', id: 2 }]); mockGetTriggerConfigsByProject.mockResolvedValue([ { agentType: 'review', @@ -209,6 +235,7 @@ describe('agentTriggerConfigsRouter — getProjectTriggersView', () => { mockListAgentDefinitions.mockResolvedValue([ { agentType: 'review', definition: definitionWithParams }, ]); + mockListAgentConfigs.mockResolvedValue([{ agentType: 'review', id: 2 }]); mockGetTriggerConfigsByProject.mockResolvedValue([ { agentType: 'review', @@ -253,12 +280,13 @@ describe('agentTriggerConfigsRouter — getProjectTriggersView', () => { // Falls back to YAML — need some types for that mockGetKnownAgentTypes.mockReturnValue(['implementation']); mockLoadAgentDefinition.mockReturnValue(makeAgentDefinition()); + mockListAgentConfigs.mockResolvedValue([{ agentType: 'implementation', id: 1 }]); const caller = createCaller(mockCtx); const result = await caller.getProjectTriggersView({ projectId: 'test-project' }); - // Should not throw; falls back to YAML - expect(result.agents).toHaveLength(1); + // Should not throw; falls back to YAML, and shows as enabled since it has a config + expect(result.enabledAgents).toHaveLength(1); expect(result.agents[0].agentType).toBe('implementation'); }); @@ -267,6 +295,7 @@ describe('agentTriggerConfigsRouter — getProjectTriggersView', () => { mockListAgentDefinitions.mockResolvedValue([{ agentType: 'implementation', definition }]); // YAML also has 'implementation' mockGetKnownAgentTypes.mockReturnValue(['implementation']); + mockListAgentConfigs.mockResolvedValue([{ agentType: 'implementation', id: 1 }]); const caller = createCaller(mockCtx); const result = await caller.getProjectTriggersView({ projectId: 'test-project' }); @@ -275,17 +304,20 @@ describe('agentTriggerConfigsRouter — getProjectTriggersView', () => { expect(result.agents).toHaveLength(1); }); - it('includes YAML-only agents not in DB', async () => { + it('enabled agents appear in enabledAgents; unconfigured appear in availableAgents', async () => { mockListAgentDefinitions.mockResolvedValue([]); // no DB definitions mockGetKnownAgentTypes.mockReturnValue(['splitting', 'planning']); mockLoadAgentDefinition.mockReturnValue(makeAgentDefinition()); + // Only 'splitting' is enabled + mockListAgentConfigs.mockResolvedValue([{ agentType: 'splitting', id: 1 }]); const caller = createCaller(mockCtx); const result = await caller.getProjectTriggersView({ projectId: 'test-project' }); - expect(result.agents).toHaveLength(2); - expect(result.agents.map((a) => a.agentType)).toContain('splitting'); - expect(result.agents.map((a) => a.agentType)).toContain('planning'); + expect(result.enabledAgents).toHaveLength(1); + expect(result.enabledAgents[0].agentType).toBe('splitting'); + expect(result.availableAgents).toContain('planning'); + expect(result.availableAgents).not.toContain('splitting'); }); it('handles YAML load failure gracefully (skips that agent)', async () => { @@ -295,11 +327,12 @@ describe('agentTriggerConfigsRouter — getProjectTriggersView', () => { .mockImplementationOnce(() => { throw new Error('YAML parse error'); }); + mockListAgentConfigs.mockResolvedValue([{ agentType: 'implementation', id: 1 }]); const caller = createCaller(mockCtx); const result = await caller.getProjectTriggersView({ projectId: 'test-project' }); - // 'failing-agent' should be skipped; 'implementation' included + // 'failing-agent' should be skipped; 'implementation' included in enabled expect(result.agents).toHaveLength(1); expect(result.agents[0].agentType).toBe('implementation'); }); @@ -312,7 +345,7 @@ describe('agentTriggerConfigsRouter — getProjectTriggersView', () => { label: 'Status Changed', description: 'When status changes', providers: null, - defaultEnabled: true, + defaultEnabled: false, parameters: [ { name: 'myParam', @@ -328,6 +361,7 @@ describe('agentTriggerConfigsRouter — getProjectTriggersView', () => { ], }; mockListAgentDefinitions.mockResolvedValue([{ agentType: 'implementation', definition }]); + mockListAgentConfigs.mockResolvedValue([{ agentType: 'implementation', id: 1 }]); const caller = createCaller(mockCtx); const result = await caller.getProjectTriggersView({ projectId: 'test-project' }); @@ -345,6 +379,7 @@ describe('agentTriggerConfigsRouter — getProjectTriggersView', () => { it('handles trigger with no parameters (empty parameterDefs and parameters)', async () => { const definition = makeAgentDefinition(); mockListAgentDefinitions.mockResolvedValue([{ agentType: 'implementation', definition }]); + mockListAgentConfigs.mockResolvedValue([{ agentType: 'implementation', id: 1 }]); const caller = createCaller(mockCtx); const result = await caller.getProjectTriggersView({ projectId: 'test-project' }); diff --git a/tests/unit/api/routers/runs.test.ts b/tests/unit/api/routers/runs.test.ts index 7a49b96f..68f39087 100644 --- a/tests/unit/api/routers/runs.test.ts +++ b/tests/unit/api/routers/runs.test.ts @@ -79,6 +79,12 @@ vi.mock('../../../../src/queue/cancel.js', () => ({ publishCancelCommand: (...args: unknown[]) => mockPublishCancelCommand(...args), })); +// Mock isAgentEnabledForProject — default: agent is enabled +const mockIsAgentEnabledForProject = vi.fn().mockResolvedValue(true); +vi.mock('../../../../src/db/repositories/agentConfigsRepository.js', () => ({ + isAgentEnabledForProject: (...args: unknown[]) => mockIsAgentEnabledForProject(...args), +})); + import { runsRouter } from '../../../../src/api/routers/runs.js'; function createCaller(ctx: TRPCContext) { @@ -851,6 +857,20 @@ describe('runsRouter', () => { ).rejects.toMatchObject({ code: 'NOT_FOUND' }); }); + it('throws BAD_REQUEST when agent is not enabled for the project', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockLoadProjectConfigById.mockResolvedValue({ + project: { id: 'p1', name: 'Test Project' }, + config: {}, + }); + mockIsAgentEnabledForProject.mockResolvedValueOnce(false); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.trigger({ projectId: 'p1', agentType: 'implementation' }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + it('throws UNAUTHORIZED when unauthenticated', async () => { const caller = createCaller({ user: null, effectiveOrgId: null }); await expect( diff --git a/tests/unit/triggers/config-resolver.test.ts b/tests/unit/triggers/config-resolver.test.ts index f0a3ec94..3965c4e5 100644 --- a/tests/unit/triggers/config-resolver.test.ts +++ b/tests/unit/triggers/config-resolver.test.ts @@ -1,12 +1,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // Hoist mocks before any imports -const { mockResolveAgentDefinition, mockGetTriggerConfig, mockGetTriggerConfigsByProjectAndAgent } = - vi.hoisted(() => ({ - mockResolveAgentDefinition: vi.fn(), - mockGetTriggerConfig: vi.fn(), - mockGetTriggerConfigsByProjectAndAgent: vi.fn(), - })); +const { + mockResolveAgentDefinition, + mockGetTriggerConfig, + mockGetTriggerConfigsByProjectAndAgent, + mockIsAgentEnabledForProject, +} = vi.hoisted(() => ({ + mockResolveAgentDefinition: vi.fn(), + mockGetTriggerConfig: vi.fn(), + mockGetTriggerConfigsByProjectAndAgent: vi.fn(), + // Default: agent is enabled (has a config row) + mockIsAgentEnabledForProject: vi.fn().mockResolvedValue(true), +})); vi.mock('../../../src/agents/definitions/index.js', () => ({ resolveAgentDefinition: mockResolveAgentDefinition, @@ -17,6 +23,10 @@ vi.mock('../../../src/db/repositories/agentTriggerConfigsRepository.js', () => ( getTriggerConfigsByProjectAndAgent: mockGetTriggerConfigsByProjectAndAgent, })); +vi.mock('../../../src/db/repositories/agentConfigsRepository.js', () => ({ + isAgentEnabledForProject: mockIsAgentEnabledForProject, +})); + import { getResolvedTriggerConfig, getTriggerParameters, @@ -68,6 +78,15 @@ function makeDbConfig(overrides: Record = {}) { describe('resolveTriggerConfigs', () => { beforeEach(() => { vi.resetAllMocks(); + // Default: agent is enabled (has a config row) + mockIsAgentEnabledForProject.mockResolvedValue(true); + }); + + it('returns empty array when agent is not enabled for project (no config row)', async () => { + mockIsAgentEnabledForProject.mockResolvedValue(false); + const result = await resolveTriggerConfigs(PROJECT_ID, AGENT_TYPE); + expect(result).toEqual([]); + expect(mockResolveAgentDefinition).not.toHaveBeenCalled(); }); it('returns empty array when agent definition is not found', async () => { @@ -143,6 +162,19 @@ describe('resolveTriggerConfigs', () => { describe('isTriggerEnabled', () => { beforeEach(() => { vi.resetAllMocks(); + // Default: agent is enabled (has a config row) + mockIsAgentEnabledForProject.mockResolvedValue(true); + }); + + it('returns false when agent has no config row (not enabled for project)', async () => { + mockIsAgentEnabledForProject.mockResolvedValue(false); + + const result = await isTriggerEnabled(PROJECT_ID, AGENT_TYPE, TRIGGER_EVENT); + + expect(result).toBe(false); + // Should not check DB trigger config or definition + expect(mockGetTriggerConfig).not.toHaveBeenCalled(); + expect(mockResolveAgentDefinition).not.toHaveBeenCalled(); }); it('returns DB override enabled value when config exists', async () => { @@ -195,6 +227,17 @@ describe('isTriggerEnabled', () => { describe('getTriggerParameters', () => { beforeEach(() => { vi.resetAllMocks(); + // Default: agent is enabled (has a config row) + mockIsAgentEnabledForProject.mockResolvedValue(true); + }); + + it('returns empty object when agent is not enabled for project (no config row)', async () => { + mockIsAgentEnabledForProject.mockResolvedValue(false); + + const result = await getTriggerParameters(PROJECT_ID, AGENT_TYPE, TRIGGER_EVENT); + + expect(result).toEqual({}); + expect(mockResolveAgentDefinition).not.toHaveBeenCalled(); }); it('returns empty object when agent definition not found', async () => { @@ -261,6 +304,17 @@ describe('getTriggerParameters', () => { describe('getResolvedTriggerConfig', () => { beforeEach(() => { vi.resetAllMocks(); + // Default: agent is enabled (has a config row) + mockIsAgentEnabledForProject.mockResolvedValue(true); + }); + + it('returns null when agent is not enabled for project (no config row)', async () => { + mockIsAgentEnabledForProject.mockResolvedValue(false); + + const result = await getResolvedTriggerConfig(PROJECT_ID, AGENT_TYPE, TRIGGER_EVENT); + + expect(result).toBeNull(); + expect(mockResolveAgentDefinition).not.toHaveBeenCalled(); }); it('returns null when agent definition not found', async () => { diff --git a/tests/unit/triggers/manual-runner.test.ts b/tests/unit/triggers/manual-runner.test.ts index 89dac37b..2fd37e21 100644 --- a/tests/unit/triggers/manual-runner.test.ts +++ b/tests/unit/triggers/manual-runner.test.ts @@ -8,6 +8,11 @@ vi.mock('../../../src/db/repositories/runsRepository.js', () => ({ getRunById: vi.fn(), })); +// Default: agent is enabled (has a config row) +vi.mock('../../../src/db/repositories/agentConfigsRepository.js', () => ({ + isAgentEnabledForProject: vi.fn().mockResolvedValue(true), +})); + vi.mock('../../../src/utils/logging.js', () => ({ logger: { info: vi.fn(), @@ -40,6 +45,7 @@ vi.mock('../../../src/triggers/shared/integration-validation.js', () => ({ })); import { runAgent } from '../../../src/agents/registry.js'; +import { isAgentEnabledForProject } from '../../../src/db/repositories/agentConfigsRepository.js'; import { getRunById } from '../../../src/db/repositories/runsRepository.js'; import { withPMCredentials } from '../../../src/pm/context.js'; import { createPMProvider, withPMProvider } from '../../../src/pm/index.js'; @@ -71,6 +77,22 @@ describe('triggerManualRun', () => { clearTriggerTracking(); }); + it('throws when agent is not enabled for the project', async () => { + vi.mocked(isAgentEnabledForProject).mockResolvedValueOnce(false); + + await expect( + triggerManualRun( + { + projectId: 'test-project', + agentType: 'implementation', + workItemId: 'card-1', + }, + mockProject, + mockConfig, + ), + ).rejects.toThrow('not enabled for project'); + }); + it('throws when trigger is already running for same project+agent+card', async () => { vi.mocked(runAgent).mockImplementation(() => new Promise(() => {})); // Never resolves diff --git a/web/src/components/projects/project-agent-configs.tsx b/web/src/components/projects/project-agent-configs.tsx index a1ce893d..e02a9b4e 100644 --- a/web/src/components/projects/project-agent-configs.tsx +++ b/web/src/components/projects/project-agent-configs.tsx @@ -591,78 +591,109 @@ function AgentRow({ } interface AgentListViewProps { - agentTypes: string[]; + enabledAgentTypes: string[]; + availableAgentTypes: string[]; configByAgent: Map; triggersByAgent: Map; integrations: { pm: string | null; scm: string | null }; onSelect: (agentType: string) => void; onDelete: (id: number) => void; + onEnable: (agentType: string) => void; isDeleting: boolean; + isEnabling: boolean; projectModel: string | null; projectEngine: string | null; systemDefaults: SystemDefaults | undefined; } function AgentListView({ - agentTypes, + enabledAgentTypes, + availableAgentTypes, configByAgent, triggersByAgent, integrations, onSelect, onDelete, + onEnable, isDeleting, + isEnabling, projectModel, projectEngine, systemDefaults, }: AgentListViewProps) { const [deleteTarget, setDeleteTarget] = useState<{ id: number; label: string } | null>(null); - if (agentTypes.length === 0) { - return ( -
No agent definitions found.
- ); - } - return ( <> -
- - - - Agent - Status - Model / Engine - Active Triggers - - - - - {agentTypes.map((type) => ( - setDeleteTarget({ id, label })} - projectModel={projectModel} - projectEngine={projectEngine} - systemDefaults={systemDefaults} - /> - ))} - -
-
+ {enabledAgentTypes.length === 0 ? ( +
+ No agents enabled. Enable agents below to start processing. +
+ ) : ( +
+ + + + Agent + Status + Model / Engine + Active Triggers + + + + + {enabledAgentTypes.map((type) => ( + setDeleteTarget({ id, label })} + projectModel={projectModel} + projectEngine={projectEngine} + systemDefaults={systemDefaults} + /> + ))} + +
+
+ )} + + {availableAgentTypes.length > 0 && ( +
+

Available Agents

+
+ {availableAgentTypes.map((agentType) => { + const label = + (AGENT_LABELS as Record)[agentType] ?? agentType; + return ( +
+ {label} + +
+ ); + })} +
+
+ )} !open && setDeleteTarget(null)}> Delete Agent Config - Are you sure you want to delete the custom config for{' '} - {deleteTarget?.label}? The agent will revert to default settings. - This action cannot be undone. + Are you sure you want to delete the config for {deleteTarget?.label}? + The agent will be disabled and no longer process any events. This action cannot be + undone. @@ -886,12 +917,39 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { const deleteMutation = useMutation({ mutationFn: (id: number) => trpcClient.agentConfigs.delete.mutate({ id }), - onSuccess: () => queryClient.invalidateQueries({ queryKey: configsQueryKey }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: configsQueryKey }); + queryClient.invalidateQueries({ queryKey: triggersViewQueryKey }); + }, onError: (err) => { toast.error('Failed to delete agent config', { description: err.message }); }, }); + // Enable an agent by creating a bare agent_configs row (no overrides = project defaults) + const enableAgentMutation = useMutation({ + mutationFn: (agentType: string) => + trpcClient.agentConfigs.create.mutate({ + projectId, + agentType, + model: null, + maxIterations: null, + agentEngine: null, + engineSettings: null, + maxConcurrency: null, + systemPrompt: null, + taskPrompt: null, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: configsQueryKey }); + queryClient.invalidateQueries({ queryKey: triggersViewQueryKey }); + toast.success('Agent enabled'); + }, + onError: (err) => { + toast.error('Failed to enable agent', { description: err.message }); + }, + }); + // New trigger mutation (uses agentTriggerConfigs.upsert) const upsertTriggerMutation = useMutation({ mutationFn: (input: { @@ -945,18 +1003,22 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { configByAgent.set(c.agentType, c); } - // Build triggers map from API + // Build triggers map from API — use enabledAgents (configured agents only) const triggersByAgent = new Map(); const triggersViewIntegrations = triggersViewQuery.data?.integrations ?? { pm: null, scm: null, }; if (triggersViewQuery.data) { - for (const agent of triggersViewQuery.data.agents) { + const agentsList = triggersViewQuery.data.enabledAgents ?? triggersViewQuery.data.agents ?? []; + for (const agent of agentsList) { triggersByAgent.set(agent.agentType, agent.triggers as ResolvedTrigger[]); } } + // Available (unconfigured) agent types + const availableAgentTypes = triggersViewQuery.data?.availableAgents ?? []; + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: save handler dispatches create vs update, builds payload from many optional fields, and chains trigger upsert const handleSaveConfig = (type: string, configId: number | null, values: SaveConfigValues) => { setSavingAgentType(type); @@ -1017,8 +1079,8 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { ); }; - // Get list of agent types to display - const agentTypes = Array.from(triggersByAgent.keys()); + // Get list of enabled agent types to display + const enabledAgentTypes = Array.from(triggersByAgent.keys()); // Render detail view when an agent is selected if (selectedAgent !== null) { @@ -1057,13 +1119,16 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) {

deleteMutation.mutate(id)} + onEnable={(agentType) => enableAgentMutation.mutate(agentType)} isDeleting={deleteMutation.isPending} + isEnabling={enableAgentMutation.isPending} projectModel={projectModel} projectEngine={projectEngine} systemDefaults={systemDefaults}