From 4d0743694de0aaf34f760126540b5bcab171befd Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Sat, 4 Apr 2026 19:48:42 +0900 Subject: [PATCH] feat(mcp): add runtime capability detection for Teams coordination (#1311) Add TeamsCapabilityService as single source of truth for whether Teams coordination strategy is available at runtime. Capability is gated via experimental.teamsCoordination config flag or CODINGBUDDY_TEAMS_ENABLED env var, defaulting to disabled. - Add experimental schema section to CodingBuddyConfigSchema - Create TeamsCapabilityService with env > config > default resolution - Create TeamsCapabilityStatus/Source types for debugging visibility - Register in AgentModule and export from barrel - 16 unit tests covering enabled/disabled/fallback/precedence paths --- apps/mcp-server/src/agent/agent.module.ts | 5 +- apps/mcp-server/src/agent/index.ts | 2 + .../agent/teams-capability.service.spec.ts | 172 ++++++++++++++++++ .../src/agent/teams-capability.service.ts | 88 +++++++++ .../src/agent/teams-capability.types.ts | 20 ++ apps/mcp-server/src/config/config.schema.ts | 8 + 6 files changed, 293 insertions(+), 2 deletions(-) create mode 100644 apps/mcp-server/src/agent/teams-capability.service.spec.ts create mode 100644 apps/mcp-server/src/agent/teams-capability.service.ts create mode 100644 apps/mcp-server/src/agent/teams-capability.types.ts diff --git a/apps/mcp-server/src/agent/agent.module.ts b/apps/mcp-server/src/agent/agent.module.ts index d32ba4bb..4c510bb7 100644 --- a/apps/mcp-server/src/agent/agent.module.ts +++ b/apps/mcp-server/src/agent/agent.module.ts @@ -2,13 +2,14 @@ import { Module } from '@nestjs/common'; import { AgentService } from './agent.service'; import { AgentStackService } from './agent-stack.service'; import { CouncilPresetService } from './council-preset.service'; +import { TeamsCapabilityService } from './teams-capability.service'; import { RulesModule } from '../rules/rules.module'; import { CustomModule } from '../custom'; import { CodingBuddyConfigModule } from '../config/config.module'; @Module({ imports: [RulesModule, CustomModule, CodingBuddyConfigModule], - providers: [AgentService, AgentStackService, CouncilPresetService], - exports: [AgentService, AgentStackService, CouncilPresetService], + providers: [AgentService, AgentStackService, CouncilPresetService, TeamsCapabilityService], + exports: [AgentService, AgentStackService, CouncilPresetService, TeamsCapabilityService], }) export class AgentModule {} diff --git a/apps/mcp-server/src/agent/index.ts b/apps/mcp-server/src/agent/index.ts index c91331cb..f10e61ae 100644 --- a/apps/mcp-server/src/agent/index.ts +++ b/apps/mcp-server/src/agent/index.ts @@ -2,6 +2,8 @@ export * from './agent.module'; export * from './agent.service'; export * from './agent-stack.service'; export * from './council-preset.service'; +export * from './teams-capability.service'; +export * from './teams-capability.types'; export * from './agent.types'; export * from './agent-prompt.builder'; export * from './agent-stack.schema'; diff --git a/apps/mcp-server/src/agent/teams-capability.service.spec.ts b/apps/mcp-server/src/agent/teams-capability.service.spec.ts new file mode 100644 index 00000000..7ac392d5 --- /dev/null +++ b/apps/mcp-server/src/agent/teams-capability.service.spec.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { TeamsCapabilityService } from './teams-capability.service'; +import type { ConfigService } from '../config/config.service'; + +function createMockConfigService(experimental?: { teamsCoordination?: boolean }): ConfigService { + return { + getSettings: vi.fn().mockResolvedValue({ experimental }), + } as unknown as ConfigService; +} + +describe('TeamsCapabilityService', () => { + let originalEnv: string | undefined; + + beforeEach(() => { + originalEnv = process.env.CODINGBUDDY_TEAMS_ENABLED; + delete process.env.CODINGBUDDY_TEAMS_ENABLED; + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.CODINGBUDDY_TEAMS_ENABLED = originalEnv; + } else { + delete process.env.CODINGBUDDY_TEAMS_ENABLED; + } + }); + + describe('getStatus', () => { + describe('default (no config, no env)', () => { + it('should be disabled by default', async () => { + const service = new TeamsCapabilityService(createMockConfigService()); + const status = await service.getStatus(); + + expect(status.available).toBe(false); + expect(status.source).toBe('default'); + expect(status.reason).toContain('disabled by default'); + }); + }); + + describe('config-based gating', () => { + it('should enable when experimental.teamsCoordination is true', async () => { + const service = new TeamsCapabilityService( + createMockConfigService({ teamsCoordination: true }), + ); + const status = await service.getStatus(); + + expect(status.available).toBe(true); + expect(status.source).toBe('config'); + expect(status.reason).toContain('Enabled via experimental.teamsCoordination'); + }); + + it('should disable when experimental.teamsCoordination is false', async () => { + const service = new TeamsCapabilityService( + createMockConfigService({ teamsCoordination: false }), + ); + const status = await service.getStatus(); + + expect(status.available).toBe(false); + expect(status.source).toBe('config'); + expect(status.reason).toContain('Disabled via experimental.teamsCoordination'); + }); + + it('should fall through to default when experimental exists but teamsCoordination is undefined', async () => { + const service = new TeamsCapabilityService(createMockConfigService({})); + const status = await service.getStatus(); + + expect(status.available).toBe(false); + expect(status.source).toBe('default'); + }); + }); + + describe('environment variable override', () => { + it('should enable when CODINGBUDDY_TEAMS_ENABLED=true', async () => { + process.env.CODINGBUDDY_TEAMS_ENABLED = 'true'; + const service = new TeamsCapabilityService(createMockConfigService()); + const status = await service.getStatus(); + + expect(status.available).toBe(true); + expect(status.source).toBe('environment'); + expect(status.reason).toContain('Enabled via CODINGBUDDY_TEAMS_ENABLED'); + }); + + it('should enable when CODINGBUDDY_TEAMS_ENABLED=1', async () => { + process.env.CODINGBUDDY_TEAMS_ENABLED = '1'; + const service = new TeamsCapabilityService(createMockConfigService()); + const status = await service.getStatus(); + + expect(status.available).toBe(true); + expect(status.source).toBe('environment'); + }); + + it('should disable when CODINGBUDDY_TEAMS_ENABLED=false', async () => { + process.env.CODINGBUDDY_TEAMS_ENABLED = 'false'; + const service = new TeamsCapabilityService(createMockConfigService()); + const status = await service.getStatus(); + + expect(status.available).toBe(false); + expect(status.source).toBe('environment'); + expect(status.reason).toContain('Disabled via CODINGBUDDY_TEAMS_ENABLED'); + }); + + it('should take precedence over config when both are set', async () => { + process.env.CODINGBUDDY_TEAMS_ENABLED = 'false'; + const service = new TeamsCapabilityService( + createMockConfigService({ teamsCoordination: true }), + ); + const status = await service.getStatus(); + + expect(status.available).toBe(false); + expect(status.source).toBe('environment'); + }); + }); + }); + + describe('isAvailable', () => { + it('should return true when enabled', async () => { + const service = new TeamsCapabilityService( + createMockConfigService({ teamsCoordination: true }), + ); + expect(await service.isAvailable()).toBe(true); + }); + + it('should return false when disabled', async () => { + const service = new TeamsCapabilityService(createMockConfigService()); + expect(await service.isAvailable()).toBe(false); + }); + }); + + describe('readEnvFlag', () => { + it('should return undefined when env var is not set', () => { + const service = new TeamsCapabilityService(createMockConfigService()); + expect(service.readEnvFlag()).toBeUndefined(); + }); + + it('should return undefined when env var is empty string', () => { + process.env.CODINGBUDDY_TEAMS_ENABLED = ''; + const service = new TeamsCapabilityService(createMockConfigService()); + expect(service.readEnvFlag()).toBeUndefined(); + }); + + it('should return true for "true"', () => { + process.env.CODINGBUDDY_TEAMS_ENABLED = 'true'; + const service = new TeamsCapabilityService(createMockConfigService()); + expect(service.readEnvFlag()).toBe(true); + }); + + it('should return true for "1"', () => { + process.env.CODINGBUDDY_TEAMS_ENABLED = '1'; + const service = new TeamsCapabilityService(createMockConfigService()); + expect(service.readEnvFlag()).toBe(true); + }); + + it('should return false for any other value', () => { + process.env.CODINGBUDDY_TEAMS_ENABLED = 'no'; + const service = new TeamsCapabilityService(createMockConfigService()); + expect(service.readEnvFlag()).toBe(false); + }); + }); + + describe('status object shape', () => { + it('should have readonly-compatible properties', async () => { + const service = new TeamsCapabilityService(createMockConfigService()); + const status = await service.getStatus(); + + expect(status).toHaveProperty('available'); + expect(status).toHaveProperty('reason'); + expect(status).toHaveProperty('source'); + expect(typeof status.available).toBe('boolean'); + expect(typeof status.reason).toBe('string'); + expect(['config', 'environment', 'default']).toContain(status.source); + }); + }); +}); diff --git a/apps/mcp-server/src/agent/teams-capability.service.ts b/apps/mcp-server/src/agent/teams-capability.service.ts new file mode 100644 index 00000000..56a352cd --- /dev/null +++ b/apps/mcp-server/src/agent/teams-capability.service.ts @@ -0,0 +1,88 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '../config/config.service'; +import type { TeamsCapabilityStatus } from './teams-capability.types'; + +/** + * Single source of truth for Teams coordination capability. + * + * Resolution order: + * 1. Environment variable `CODINGBUDDY_TEAMS_ENABLED` (explicit override) + * 2. Config flag `experimental.teamsCoordination` in codingbuddy.config.json + * 3. Default: disabled + */ +@Injectable() +export class TeamsCapabilityService { + private readonly logger = new Logger(TeamsCapabilityService.name); + + constructor(private readonly configService: ConfigService) {} + + /** + * Check whether Teams coordination is available at runtime. + * Returns a status object suitable for debugging and handler decisions. + */ + async getStatus(): Promise { + // 1. Environment variable override (highest priority) + const envValue = this.readEnvFlag(); + if (envValue !== undefined) { + const status: TeamsCapabilityStatus = { + available: envValue, + reason: envValue + ? 'Enabled via CODINGBUDDY_TEAMS_ENABLED environment variable' + : 'Disabled via CODINGBUDDY_TEAMS_ENABLED environment variable', + source: 'environment', + }; + this.logger.debug(`Teams capability: ${status.reason}`); + return status; + } + + // 2. Config flag + const configValue = await this.readConfigFlag(); + if (configValue !== undefined) { + const status: TeamsCapabilityStatus = { + available: configValue, + reason: configValue + ? 'Enabled via experimental.teamsCoordination config' + : 'Disabled via experimental.teamsCoordination config', + source: 'config', + }; + this.logger.debug(`Teams capability: ${status.reason}`); + return status; + } + + // 3. Default: disabled + const status: TeamsCapabilityStatus = { + available: false, + reason: 'Teams coordination is experimental and disabled by default', + source: 'default', + }; + this.logger.debug(`Teams capability: ${status.reason}`); + return status; + } + + /** + * Convenience: returns true when Teams coordination is available. + */ + async isAvailable(): Promise { + const status = await this.getStatus(); + return status.available; + } + + /** + * Read the CODINGBUDDY_TEAMS_ENABLED environment variable. + * Returns undefined when not set, boolean when explicitly set. + */ + readEnvFlag(): boolean | undefined { + const raw = process.env.CODINGBUDDY_TEAMS_ENABLED; + if (raw === undefined || raw === '') return undefined; + return raw === 'true' || raw === '1'; + } + + /** + * Read the experimental.teamsCoordination config flag. + * Returns undefined when not set, boolean when explicitly set. + */ + private async readConfigFlag(): Promise { + const settings = await this.configService.getSettings(); + return settings.experimental?.teamsCoordination ?? undefined; + } +} diff --git a/apps/mcp-server/src/agent/teams-capability.types.ts b/apps/mcp-server/src/agent/teams-capability.types.ts new file mode 100644 index 00000000..64d8d14e --- /dev/null +++ b/apps/mcp-server/src/agent/teams-capability.types.ts @@ -0,0 +1,20 @@ +/** + * Teams coordination capability status. + * Single source of truth for whether Teams strategy is available at runtime. + */ +export interface TeamsCapabilityStatus { + /** Whether Teams coordination is enabled and available */ + readonly available: boolean; + /** Human-readable reason for the current state */ + readonly reason: string; + /** Source of the capability decision */ + readonly source: TeamsCapabilitySource; +} + +/** + * How the capability decision was determined. + */ +export type TeamsCapabilitySource = + | 'config' // experimental.teamsCoordination in codingbuddy.config.json + | 'environment' // CODINGBUDDY_TEAMS_ENABLED env var + | 'default'; // no explicit signal, default to disabled diff --git a/apps/mcp-server/src/config/config.schema.ts b/apps/mcp-server/src/config/config.schema.ts index 0b74143a..9d5bd32e 100644 --- a/apps/mcp-server/src/config/config.schema.ts +++ b/apps/mcp-server/src/config/config.schema.ts @@ -209,6 +209,14 @@ export const CodingBuddyConfigSchema = z.object({ // Context document limits (DoS prevention) context: ContextConfigSchema.optional(), + // Experimental feature flags + experimental: z + .object({ + /** Enable Teams coordination strategy for nested agent dispatch. Default: false */ + teamsCoordination: z.boolean().default(false).optional(), + }) + .optional(), + // Upstream Repository Mapping (for cross-repo issue creation) upstreamRepos: z.record(z.string(), z.string()).optional(),