Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/mcp-server/src/agent/agent.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
2 changes: 2 additions & 0 deletions apps/mcp-server/src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
172 changes: 172 additions & 0 deletions apps/mcp-server/src/agent/teams-capability.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
88 changes: 88 additions & 0 deletions apps/mcp-server/src/agent/teams-capability.service.ts
Original file line number Diff line number Diff line change
@@ -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<TeamsCapabilityStatus> {
// 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<boolean> {
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<boolean | undefined> {
const settings = await this.configService.getSettings();
return settings.experimental?.teamsCoordination ?? undefined;
}
}
20 changes: 20 additions & 0 deletions apps/mcp-server/src/agent/teams-capability.types.ts
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions apps/mcp-server/src/config/config.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),

Expand Down
Loading