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
221 changes: 221 additions & 0 deletions apps/mcp-server/src/agent/agent-stack.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { AgentStackService } from './agent-stack.service';
import type { RulesService } from '../rules/rules.service';
import type { ConfigService } from '../config/config.service';
import * as fs from 'fs/promises';

vi.mock('fs/promises');

const mockFs = vi.mocked(fs);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockReaddir = mockFs.readdir as any as ReturnType<typeof vi.fn>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockReadFile = mockFs.readFile as any as ReturnType<typeof vi.fn>;

describe('AgentStackService', () => {
let service: AgentStackService;
let mockRulesService: Partial<RulesService>;
let mockConfigService: Partial<ConfigService>;

const sampleStack = {
name: 'api-development',
description: 'API development stack',
category: 'development',
primary_agent: 'backend-developer',
specialists: ['security-specialist', 'test-engineer'],
tags: ['api', 'backend'],
};

const sampleStack2 = {
name: 'frontend-review',
description: 'Frontend review stack',
category: 'review',
primary_agent: 'frontend-developer',
specialists: ['accessibility-specialist', 'performance-specialist'],
tags: ['frontend', 'ui'],
};

beforeEach(() => {
mockRulesService = {
getRulesDir: vi.fn().mockReturnValue('/rules/.ai-rules'),
};
mockConfigService = {
getProjectRoot: vi.fn().mockReturnValue('/project'),
};
service = new AgentStackService(
mockRulesService as RulesService,
mockConfigService as ConfigService,
);
});

afterEach(() => {
vi.restoreAllMocks();
});

describe('listStacks', () => {
it('should return stacks from default location', async () => {
mockReaddir.mockImplementation(async (dirPath: string) => {
if (dirPath.includes('.ai-rules/agent-stacks')) {
return ['api-development.json', 'frontend-review.json'];
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
mockReadFile.mockImplementation(async (filePath: string) => {
if (filePath.includes('api-development.json')) return JSON.stringify(sampleStack);
if (filePath.includes('frontend-review.json')) return JSON.stringify(sampleStack2);
throw new Error('Not found');
});

const stacks = await service.listStacks();

expect(stacks).toHaveLength(2);
expect(stacks[0].name).toBe('api-development');
expect(stacks[0].specialist_count).toBe(2);
expect(stacks[1].name).toBe('frontend-review');
});

it('should merge custom stacks with default, custom taking priority', async () => {
const customStack = {
...sampleStack,
description: 'Custom API stack',
specialists: ['security-specialist'],
};

mockReaddir.mockImplementation(async (dirPath: string) => {
if (dirPath.includes('.codingbuddy/agent-stacks')) {
return ['api-development.json'];
}
if (dirPath.includes('.ai-rules/agent-stacks')) {
return ['api-development.json', 'frontend-review.json'];
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
mockReadFile.mockImplementation(async (filePath: string) => {
if (filePath.includes('.codingbuddy') && filePath.includes('api-development.json')) {
return JSON.stringify(customStack);
}
if (filePath.includes('api-development.json')) return JSON.stringify(sampleStack);
if (filePath.includes('frontend-review.json')) return JSON.stringify(sampleStack2);
throw new Error('Not found');
});

const stacks = await service.listStacks();

expect(stacks).toHaveLength(2);
const apiStack = stacks.find(s => s.name === 'api-development');
expect(apiStack?.description).toBe('Custom API stack');
expect(apiStack?.specialist_count).toBe(1);
});

it('should filter by category', async () => {
mockReaddir.mockImplementation(async (dirPath: string) => {
if (dirPath.includes('.ai-rules/agent-stacks')) {
return ['api-development.json', 'frontend-review.json'];
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
mockReadFile.mockImplementation(async (filePath: string) => {
if (filePath.includes('api-development.json')) return JSON.stringify(sampleStack);
if (filePath.includes('frontend-review.json')) return JSON.stringify(sampleStack2);
throw new Error('Not found');
});

const stacks = await service.listStacks('review');

expect(stacks).toHaveLength(1);
expect(stacks[0].name).toBe('frontend-review');
});

it('should return empty array when no stack directories exist', async () => {
mockReaddir.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));

const stacks = await service.listStacks();

expect(stacks).toEqual([]);
});

it('should skip invalid JSON files gracefully', async () => {
mockReaddir.mockImplementation(async (dirPath: string) => {
if (dirPath.includes('.ai-rules/agent-stacks')) {
return ['valid.json', 'invalid.json', 'not-json.txt'];
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
mockReadFile.mockImplementation(async (filePath: string) => {
if (filePath.includes('valid.json')) return JSON.stringify(sampleStack);
if (filePath.includes('invalid.json')) return 'not valid json {{{';
throw new Error('Not found');
});

const stacks = await service.listStacks();

expect(stacks).toHaveLength(1);
expect(stacks[0].name).toBe('api-development');
});

it('should skip stacks missing required fields', async () => {
const incompleteStack = { name: 'incomplete' };

mockReaddir.mockImplementation(async (dirPath: string) => {
if (dirPath.includes('.ai-rules/agent-stacks')) {
return ['valid.json', 'incomplete.json'];
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
mockReadFile.mockImplementation(async (filePath: string) => {
if (filePath.includes('valid.json')) return JSON.stringify(sampleStack);
if (filePath.includes('incomplete.json')) return JSON.stringify(incompleteStack);
throw new Error('Not found');
});

const stacks = await service.listStacks();

expect(stacks).toHaveLength(1);
});
});

describe('resolveStack', () => {
it('should resolve stack by name from default location', async () => {
mockReadFile.mockImplementation(async (filePath: string) => {
if (filePath.includes('api-development.json')) {
return JSON.stringify(sampleStack);
}
throw new Error('Not found');
});

const stack = await service.resolveStack('api-development');

expect(stack.name).toBe('api-development');
expect(stack.primary_agent).toBe('backend-developer');
expect(stack.specialists).toEqual(['security-specialist', 'test-engineer']);
});

it('should prefer custom stack over default', async () => {
const customStack = {
...sampleStack,
specialists: ['security-specialist', 'test-engineer', 'code-quality-specialist'],
};

mockReadFile.mockImplementation(async (filePath: string) => {
if (filePath.includes('.codingbuddy') && filePath.includes('api-development.json')) {
return JSON.stringify(customStack);
}
if (filePath.includes('api-development.json')) return JSON.stringify(sampleStack);
throw new Error('Not found');
});

const stack = await service.resolveStack('api-development');

expect(stack.specialists).toHaveLength(3);
});

it('should throw when stack not found', async () => {
mockReadFile.mockRejectedValue(new Error('ENOENT'));

await expect(service.resolveStack('non-existent')).rejects.toThrow(
"Agent stack 'non-existent' not found",
);
});
});
});
121 changes: 121 additions & 0 deletions apps/mcp-server/src/agent/agent-stack.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { Injectable, Logger } from '@nestjs/common';
import * as fs from 'fs/promises';
import * as path from 'path';
import { RulesService } from '../rules/rules.service';
import { ConfigService } from '../config/config.service';
import type { AgentStack, AgentStackSummary } from './agent.types';

const AGENT_STACKS_DIR = 'agent-stacks';
const CUSTOM_STACKS_DIR = '.codingbuddy/agent-stacks';

@Injectable()
export class AgentStackService {
private readonly logger = new Logger(AgentStackService.name);

constructor(
private readonly rulesService: RulesService,
private readonly configService: ConfigService,
) {}

/**
* List all available agent stacks, optionally filtered by category.
* Custom stacks (.codingbuddy/agent-stacks/) override default stacks with the same name.
*/
async listStacks(category?: string): Promise<AgentStackSummary[]> {
const stackMap = new Map<string, AgentStack>();

// Load default stacks first
const defaultDir = path.join(this.rulesService.getRulesDir(), AGENT_STACKS_DIR);
const defaultStacks = await this.loadStacksFromDir(defaultDir);
for (const stack of defaultStacks) {
stackMap.set(stack.name, stack);
}

// Load custom stacks (override defaults)
const customDir = path.join(this.configService.getProjectRoot(), CUSTOM_STACKS_DIR);
const customStacks = await this.loadStacksFromDir(customDir);
for (const stack of customStacks) {
stackMap.set(stack.name, stack);
}

let stacks = Array.from(stackMap.values());

if (category) {
stacks = stacks.filter(s => s.category === category);
}

return stacks.map(this.toSummary);
}

/**
* Resolve a stack by name.
* Resolution order: custom (.codingbuddy/agent-stacks/) > default (rules/agent-stacks/).
*/
async resolveStack(stackName: string): Promise<AgentStack> {
// Try custom first
const customDir = path.join(this.configService.getProjectRoot(), CUSTOM_STACKS_DIR);
const customStack = await this.loadStackFile(path.join(customDir, `${stackName}.json`));
if (customStack) return customStack;

// Fall back to default
const defaultDir = path.join(this.rulesService.getRulesDir(), AGENT_STACKS_DIR);
const defaultStack = await this.loadStackFile(path.join(defaultDir, `${stackName}.json`));
if (defaultStack) return defaultStack;

throw new Error(`Agent stack '${stackName}' not found`);
}

private async loadStacksFromDir(dirPath: string): Promise<AgentStack[]> {
let entries: string[];
try {
entries = (await fs.readdir(dirPath)) as string[];
} catch {
return [];
}

const stacks: AgentStack[] = [];
for (const entry of entries) {
if (!entry.endsWith('.json')) continue;
const stack = await this.loadStackFile(path.join(dirPath, entry));
if (stack) stacks.push(stack);
}
return stacks;
}

private async loadStackFile(filePath: string): Promise<AgentStack | null> {
try {
const content = await fs.readFile(filePath, 'utf-8');
const parsed = JSON.parse(content) as Record<string, unknown>;

if (!this.isValidStack(parsed)) {
this.logger.warn(`Invalid agent stack file (missing required fields): ${filePath}`);
return null;
}

return parsed as unknown as AgentStack;
} catch {
return null;
}
}

private isValidStack(obj: Record<string, unknown>): boolean {
return (
typeof obj.name === 'string' &&
typeof obj.description === 'string' &&
typeof obj.category === 'string' &&
typeof obj.primary_agent === 'string' &&
Array.isArray(obj.specialists)
);
}

private toSummary(stack: AgentStack): AgentStackSummary {
return {
name: stack.name,
description: stack.description,
category: stack.category,
primary_agent: stack.primary_agent,
specialist_count: stack.specialists.length,
tags: stack.tags ?? [],
};
}
}
5 changes: 3 additions & 2 deletions apps/mcp-server/src/agent/agent.module.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common';
import { AgentService } from './agent.service';
import { AgentStackService } from './agent-stack.service';
import { RulesModule } from '../rules/rules.module';
import { CustomModule } from '../custom';
import { CodingBuddyConfigModule } from '../config/config.module';

@Module({
imports: [RulesModule, CustomModule, CodingBuddyConfigModule],
providers: [AgentService],
exports: [AgentService],
providers: [AgentService, AgentStackService],
exports: [AgentService, AgentStackService],
})
export class AgentModule {}
26 changes: 26 additions & 0 deletions apps/mcp-server/src/agent/agent.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,30 @@ export interface InlineAgentDefinition {
[key: string]: unknown;
}

/**
* Agent stack definition — a preset combination of primary agent + specialists
*/
export interface AgentStack {
name: string;
description: string;
category: string;
primary_agent: string;
specialists: string[];
tags?: string[];
}

/**
* Summary of an agent stack for listing
*/
export interface AgentStackSummary {
name: string;
description: string;
category: string;
primary_agent: string;
specialist_count: number;
tags: string[];
}

/**
* Input parameters for the dispatch_agents tool
*/
Expand All @@ -178,6 +202,8 @@ export interface DispatchAgentsInput {
specialists?: string[];
includeParallel?: boolean;
primaryAgent?: string;
/** Agent stack name to resolve primary + specialists from a preset */
agentStack?: string;
/** Execution strategy: 'subagent' (default), 'taskmaestro', or 'teams' */
executionStrategy?: 'subagent' | 'taskmaestro' | 'teams';
/** Inline agent definitions keyed by agent ID, highest priority in resolution */
Expand Down
Loading
Loading