Skip to content

Commit 4d07436

Browse files
committed
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
1 parent 3d2d4ce commit 4d07436

6 files changed

Lines changed: 293 additions & 2 deletions

File tree

apps/mcp-server/src/agent/agent.module.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@ import { Module } from '@nestjs/common';
22
import { AgentService } from './agent.service';
33
import { AgentStackService } from './agent-stack.service';
44
import { CouncilPresetService } from './council-preset.service';
5+
import { TeamsCapabilityService } from './teams-capability.service';
56
import { RulesModule } from '../rules/rules.module';
67
import { CustomModule } from '../custom';
78
import { CodingBuddyConfigModule } from '../config/config.module';
89

910
@Module({
1011
imports: [RulesModule, CustomModule, CodingBuddyConfigModule],
11-
providers: [AgentService, AgentStackService, CouncilPresetService],
12-
exports: [AgentService, AgentStackService, CouncilPresetService],
12+
providers: [AgentService, AgentStackService, CouncilPresetService, TeamsCapabilityService],
13+
exports: [AgentService, AgentStackService, CouncilPresetService, TeamsCapabilityService],
1314
})
1415
export class AgentModule {}

apps/mcp-server/src/agent/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ export * from './agent.module';
22
export * from './agent.service';
33
export * from './agent-stack.service';
44
export * from './council-preset.service';
5+
export * from './teams-capability.service';
6+
export * from './teams-capability.types';
57
export * from './agent.types';
68
export * from './agent-prompt.builder';
79
export * from './agent-stack.schema';
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2+
import { TeamsCapabilityService } from './teams-capability.service';
3+
import type { ConfigService } from '../config/config.service';
4+
5+
function createMockConfigService(experimental?: { teamsCoordination?: boolean }): ConfigService {
6+
return {
7+
getSettings: vi.fn().mockResolvedValue({ experimental }),
8+
} as unknown as ConfigService;
9+
}
10+
11+
describe('TeamsCapabilityService', () => {
12+
let originalEnv: string | undefined;
13+
14+
beforeEach(() => {
15+
originalEnv = process.env.CODINGBUDDY_TEAMS_ENABLED;
16+
delete process.env.CODINGBUDDY_TEAMS_ENABLED;
17+
});
18+
19+
afterEach(() => {
20+
if (originalEnv !== undefined) {
21+
process.env.CODINGBUDDY_TEAMS_ENABLED = originalEnv;
22+
} else {
23+
delete process.env.CODINGBUDDY_TEAMS_ENABLED;
24+
}
25+
});
26+
27+
describe('getStatus', () => {
28+
describe('default (no config, no env)', () => {
29+
it('should be disabled by default', async () => {
30+
const service = new TeamsCapabilityService(createMockConfigService());
31+
const status = await service.getStatus();
32+
33+
expect(status.available).toBe(false);
34+
expect(status.source).toBe('default');
35+
expect(status.reason).toContain('disabled by default');
36+
});
37+
});
38+
39+
describe('config-based gating', () => {
40+
it('should enable when experimental.teamsCoordination is true', async () => {
41+
const service = new TeamsCapabilityService(
42+
createMockConfigService({ teamsCoordination: true }),
43+
);
44+
const status = await service.getStatus();
45+
46+
expect(status.available).toBe(true);
47+
expect(status.source).toBe('config');
48+
expect(status.reason).toContain('Enabled via experimental.teamsCoordination');
49+
});
50+
51+
it('should disable when experimental.teamsCoordination is false', async () => {
52+
const service = new TeamsCapabilityService(
53+
createMockConfigService({ teamsCoordination: false }),
54+
);
55+
const status = await service.getStatus();
56+
57+
expect(status.available).toBe(false);
58+
expect(status.source).toBe('config');
59+
expect(status.reason).toContain('Disabled via experimental.teamsCoordination');
60+
});
61+
62+
it('should fall through to default when experimental exists but teamsCoordination is undefined', async () => {
63+
const service = new TeamsCapabilityService(createMockConfigService({}));
64+
const status = await service.getStatus();
65+
66+
expect(status.available).toBe(false);
67+
expect(status.source).toBe('default');
68+
});
69+
});
70+
71+
describe('environment variable override', () => {
72+
it('should enable when CODINGBUDDY_TEAMS_ENABLED=true', async () => {
73+
process.env.CODINGBUDDY_TEAMS_ENABLED = 'true';
74+
const service = new TeamsCapabilityService(createMockConfigService());
75+
const status = await service.getStatus();
76+
77+
expect(status.available).toBe(true);
78+
expect(status.source).toBe('environment');
79+
expect(status.reason).toContain('Enabled via CODINGBUDDY_TEAMS_ENABLED');
80+
});
81+
82+
it('should enable when CODINGBUDDY_TEAMS_ENABLED=1', async () => {
83+
process.env.CODINGBUDDY_TEAMS_ENABLED = '1';
84+
const service = new TeamsCapabilityService(createMockConfigService());
85+
const status = await service.getStatus();
86+
87+
expect(status.available).toBe(true);
88+
expect(status.source).toBe('environment');
89+
});
90+
91+
it('should disable when CODINGBUDDY_TEAMS_ENABLED=false', async () => {
92+
process.env.CODINGBUDDY_TEAMS_ENABLED = 'false';
93+
const service = new TeamsCapabilityService(createMockConfigService());
94+
const status = await service.getStatus();
95+
96+
expect(status.available).toBe(false);
97+
expect(status.source).toBe('environment');
98+
expect(status.reason).toContain('Disabled via CODINGBUDDY_TEAMS_ENABLED');
99+
});
100+
101+
it('should take precedence over config when both are set', async () => {
102+
process.env.CODINGBUDDY_TEAMS_ENABLED = 'false';
103+
const service = new TeamsCapabilityService(
104+
createMockConfigService({ teamsCoordination: true }),
105+
);
106+
const status = await service.getStatus();
107+
108+
expect(status.available).toBe(false);
109+
expect(status.source).toBe('environment');
110+
});
111+
});
112+
});
113+
114+
describe('isAvailable', () => {
115+
it('should return true when enabled', async () => {
116+
const service = new TeamsCapabilityService(
117+
createMockConfigService({ teamsCoordination: true }),
118+
);
119+
expect(await service.isAvailable()).toBe(true);
120+
});
121+
122+
it('should return false when disabled', async () => {
123+
const service = new TeamsCapabilityService(createMockConfigService());
124+
expect(await service.isAvailable()).toBe(false);
125+
});
126+
});
127+
128+
describe('readEnvFlag', () => {
129+
it('should return undefined when env var is not set', () => {
130+
const service = new TeamsCapabilityService(createMockConfigService());
131+
expect(service.readEnvFlag()).toBeUndefined();
132+
});
133+
134+
it('should return undefined when env var is empty string', () => {
135+
process.env.CODINGBUDDY_TEAMS_ENABLED = '';
136+
const service = new TeamsCapabilityService(createMockConfigService());
137+
expect(service.readEnvFlag()).toBeUndefined();
138+
});
139+
140+
it('should return true for "true"', () => {
141+
process.env.CODINGBUDDY_TEAMS_ENABLED = 'true';
142+
const service = new TeamsCapabilityService(createMockConfigService());
143+
expect(service.readEnvFlag()).toBe(true);
144+
});
145+
146+
it('should return true for "1"', () => {
147+
process.env.CODINGBUDDY_TEAMS_ENABLED = '1';
148+
const service = new TeamsCapabilityService(createMockConfigService());
149+
expect(service.readEnvFlag()).toBe(true);
150+
});
151+
152+
it('should return false for any other value', () => {
153+
process.env.CODINGBUDDY_TEAMS_ENABLED = 'no';
154+
const service = new TeamsCapabilityService(createMockConfigService());
155+
expect(service.readEnvFlag()).toBe(false);
156+
});
157+
});
158+
159+
describe('status object shape', () => {
160+
it('should have readonly-compatible properties', async () => {
161+
const service = new TeamsCapabilityService(createMockConfigService());
162+
const status = await service.getStatus();
163+
164+
expect(status).toHaveProperty('available');
165+
expect(status).toHaveProperty('reason');
166+
expect(status).toHaveProperty('source');
167+
expect(typeof status.available).toBe('boolean');
168+
expect(typeof status.reason).toBe('string');
169+
expect(['config', 'environment', 'default']).toContain(status.source);
170+
});
171+
});
172+
});
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { ConfigService } from '../config/config.service';
3+
import type { TeamsCapabilityStatus } from './teams-capability.types';
4+
5+
/**
6+
* Single source of truth for Teams coordination capability.
7+
*
8+
* Resolution order:
9+
* 1. Environment variable `CODINGBUDDY_TEAMS_ENABLED` (explicit override)
10+
* 2. Config flag `experimental.teamsCoordination` in codingbuddy.config.json
11+
* 3. Default: disabled
12+
*/
13+
@Injectable()
14+
export class TeamsCapabilityService {
15+
private readonly logger = new Logger(TeamsCapabilityService.name);
16+
17+
constructor(private readonly configService: ConfigService) {}
18+
19+
/**
20+
* Check whether Teams coordination is available at runtime.
21+
* Returns a status object suitable for debugging and handler decisions.
22+
*/
23+
async getStatus(): Promise<TeamsCapabilityStatus> {
24+
// 1. Environment variable override (highest priority)
25+
const envValue = this.readEnvFlag();
26+
if (envValue !== undefined) {
27+
const status: TeamsCapabilityStatus = {
28+
available: envValue,
29+
reason: envValue
30+
? 'Enabled via CODINGBUDDY_TEAMS_ENABLED environment variable'
31+
: 'Disabled via CODINGBUDDY_TEAMS_ENABLED environment variable',
32+
source: 'environment',
33+
};
34+
this.logger.debug(`Teams capability: ${status.reason}`);
35+
return status;
36+
}
37+
38+
// 2. Config flag
39+
const configValue = await this.readConfigFlag();
40+
if (configValue !== undefined) {
41+
const status: TeamsCapabilityStatus = {
42+
available: configValue,
43+
reason: configValue
44+
? 'Enabled via experimental.teamsCoordination config'
45+
: 'Disabled via experimental.teamsCoordination config',
46+
source: 'config',
47+
};
48+
this.logger.debug(`Teams capability: ${status.reason}`);
49+
return status;
50+
}
51+
52+
// 3. Default: disabled
53+
const status: TeamsCapabilityStatus = {
54+
available: false,
55+
reason: 'Teams coordination is experimental and disabled by default',
56+
source: 'default',
57+
};
58+
this.logger.debug(`Teams capability: ${status.reason}`);
59+
return status;
60+
}
61+
62+
/**
63+
* Convenience: returns true when Teams coordination is available.
64+
*/
65+
async isAvailable(): Promise<boolean> {
66+
const status = await this.getStatus();
67+
return status.available;
68+
}
69+
70+
/**
71+
* Read the CODINGBUDDY_TEAMS_ENABLED environment variable.
72+
* Returns undefined when not set, boolean when explicitly set.
73+
*/
74+
readEnvFlag(): boolean | undefined {
75+
const raw = process.env.CODINGBUDDY_TEAMS_ENABLED;
76+
if (raw === undefined || raw === '') return undefined;
77+
return raw === 'true' || raw === '1';
78+
}
79+
80+
/**
81+
* Read the experimental.teamsCoordination config flag.
82+
* Returns undefined when not set, boolean when explicitly set.
83+
*/
84+
private async readConfigFlag(): Promise<boolean | undefined> {
85+
const settings = await this.configService.getSettings();
86+
return settings.experimental?.teamsCoordination ?? undefined;
87+
}
88+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Teams coordination capability status.
3+
* Single source of truth for whether Teams strategy is available at runtime.
4+
*/
5+
export interface TeamsCapabilityStatus {
6+
/** Whether Teams coordination is enabled and available */
7+
readonly available: boolean;
8+
/** Human-readable reason for the current state */
9+
readonly reason: string;
10+
/** Source of the capability decision */
11+
readonly source: TeamsCapabilitySource;
12+
}
13+
14+
/**
15+
* How the capability decision was determined.
16+
*/
17+
export type TeamsCapabilitySource =
18+
| 'config' // experimental.teamsCoordination in codingbuddy.config.json
19+
| 'environment' // CODINGBUDDY_TEAMS_ENABLED env var
20+
| 'default'; // no explicit signal, default to disabled

apps/mcp-server/src/config/config.schema.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,14 @@ export const CodingBuddyConfigSchema = z.object({
209209
// Context document limits (DoS prevention)
210210
context: ContextConfigSchema.optional(),
211211

212+
// Experimental feature flags
213+
experimental: z
214+
.object({
215+
/** Enable Teams coordination strategy for nested agent dispatch. Default: false */
216+
teamsCoordination: z.boolean().default(false).optional(),
217+
})
218+
.optional(),
219+
212220
// Upstream Repository Mapping (for cross-repo issue creation)
213221
upstreamRepos: z.record(z.string(), z.string()).optional(),
214222

0 commit comments

Comments
 (0)