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
15 changes: 3 additions & 12 deletions apps/mcp-server/src/agent/council-preset.service.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,7 @@
import { Injectable } from '@nestjs/common';
import type { Mode } from '../keyword/keyword.types';
import type { CouncilMode, CouncilPreset } from './council-preset.types';

/**
* A deterministic council preset: a primary agent plus specialist reviewers
*/
export interface CouncilPreset {
mode: 'PLAN' | 'EVAL';
primary: string;
specialists: string[];
}

type CouncilMode = Extract<Mode, 'PLAN' | 'EVAL'>;
export type { CouncilPreset } from './council-preset.types';

const COUNCIL_PRESETS: Record<CouncilMode, CouncilPreset> = {
PLAN: {
Expand All @@ -36,7 +27,7 @@ export class CouncilPresetService {
* Resolve the council preset for a given mode.
* Returns null for modes without a preset (ACT, AUTO).
*/
resolvePreset(mode: Mode): CouncilPreset | null {
resolvePreset(mode: string): CouncilPreset | null {
const preset = COUNCIL_PRESETS[mode as CouncilMode];
if (!preset) return null;
return { ...preset, specialists: [...preset.specialists] };
Expand Down
16 changes: 16 additions & 0 deletions apps/mcp-server/src/agent/council-preset.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Council Preset Types — extracted to break circular dependency
* between keyword.types and council-preset.service.
*/

/** Modes that support council presets */
export type CouncilMode = 'PLAN' | 'EVAL';

/**
* A deterministic council preset: a primary agent plus specialist reviewers.
*/
export interface CouncilPreset {
mode: CouncilMode;
primary: string;
specialists: string[];
}
16 changes: 16 additions & 0 deletions apps/mcp-server/src/keyword/keyword.types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { DiffAnalysisResult } from './diff-analyzer';
import type { CouncilPreset } from '../agent/council-preset.types';
import type { CouncilSummary } from '../collaboration/council-summary.types';

export const KEYWORDS = ['PLAN', 'ACT', 'EVAL', 'AUTO'] as const;

Expand Down Expand Up @@ -507,6 +509,20 @@ export interface ParseModeResult {
* Present when staged or unstaged changes exist in the working directory.
*/
diffAnalysis?: DiffAnalysisResult;
/**
* @apiProperty External API - do not rename.
* Deterministic council preset for the current mode.
* Present in PLAN and EVAL modes — lists the primary agent and specialist reviewers
* that form the council for that mode. Absent for ACT/AUTO modes.
*/
councilPreset?: CouncilPreset;
/**
* @apiProperty External API - do not rename.
* Structured consensus summary from specialist council outputs.
* Present only when specialist results have been collected and aggregated.
* Clients should treat this as read-only diagnostic data.
*/
councilSummary?: CouncilSummary;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { ModelResolverService } from '../../model/model-resolver.service';
import { StateService } from '../../state/state.service';
import { ContextDocumentService } from '../../context/context-document.service';
import { DiagnosticLogService } from '../../diagnostic/diagnostic-log.service';
import { CouncilPresetService } from '../../agent/council-preset.service';
import type { ImpactEventService } from '../../impact';
import type { RuleEventCollector } from '../../rules/rule-event-collector';

Expand Down Expand Up @@ -179,6 +180,7 @@ describe('Handler Security Integration', () => {
mockContextDocService,
mockDiagnosticLogService,
mockAgentServiceForMode as AgentService,
new CouncilPresetService(),
mockImpactEventService,
mockRuleEventCollector,
);
Expand Down
107 changes: 107 additions & 0 deletions apps/mcp-server/src/mcp/handlers/mode.handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { StateService } from '../../state/state.service';
import { ContextDocumentService } from '../../context/context-document.service';
import { DiagnosticLogService } from '../../diagnostic/diagnostic-log.service';
import type { AgentService } from '../../agent/agent.service';
import { CouncilPresetService } from '../../agent/council-preset.service';
import type { ImpactEventService } from '../../impact';
import type { RuleEventCollector } from '../../rules/rule-event-collector';

Expand All @@ -21,6 +22,7 @@ describe('ModeHandler', () => {
let mockContextDocService: ContextDocumentService;
let mockDiagnosticLogService: DiagnosticLogService;
let mockAgentService: Partial<AgentService>;
let mockCouncilPresetService: CouncilPresetService;
let mockImpactEventService: Partial<ImpactEventService>;
let mockRuleEventCollector: Partial<RuleEventCollector>;

Expand Down Expand Up @@ -129,6 +131,8 @@ describe('ModeHandler', () => {
}),
};

mockCouncilPresetService = new CouncilPresetService();

mockImpactEventService = {
logEvent: vi.fn(),
};
Expand All @@ -144,6 +148,7 @@ describe('ModeHandler', () => {
mockContextDocService,
mockDiagnosticLogService,
mockAgentService as AgentService,
mockCouncilPresetService,
mockImpactEventService as ImpactEventService,
mockRuleEventCollector as RuleEventCollector,
);
Expand Down Expand Up @@ -1251,4 +1256,106 @@ describe('ModeHandler', () => {
expect(result?.isError).toBeFalsy();
});
});

describe('council preset integration', () => {
it('should include councilPreset in PLAN mode response', async () => {
const result = await handler.handle('parse_mode', {
prompt: 'PLAN design auth feature',
});

expect(result?.isError).toBeFalsy();
const parsed = JSON.parse((result?.content[0] as { text: string }).text);
expect(parsed.councilPreset).toBeDefined();
expect(parsed.councilPreset.mode).toBe('PLAN');
expect(parsed.councilPreset.primary).toBe('technical-planner');
expect(parsed.councilPreset.specialists).toEqual(
expect.arrayContaining([
'architecture-specialist',
'test-strategy-specialist',
'code-quality-specialist',
'security-specialist',
]),
);
});

it('should include councilPreset in EVAL mode response', async () => {
mockKeywordService.parseMode = vi.fn().mockResolvedValue({
...mockParseModeResult,
mode: 'EVAL',
originalPrompt: 'evaluate implementation',
});

const result = await handler.handle('parse_mode', {
prompt: 'EVAL evaluate implementation',
});

expect(result?.isError).toBeFalsy();
const parsed = JSON.parse((result?.content[0] as { text: string }).text);
expect(parsed.councilPreset).toBeDefined();
expect(parsed.councilPreset.mode).toBe('EVAL');
expect(parsed.councilPreset.primary).toBe('code-reviewer');
expect(parsed.councilPreset.specialists).toEqual(
expect.arrayContaining([
'security-specialist',
'performance-specialist',
'accessibility-specialist',
]),
);
});

it('should NOT include councilPreset in ACT mode response', async () => {
mockKeywordService.parseMode = vi.fn().mockResolvedValue({
...mockParseModeResult,
mode: 'ACT',
originalPrompt: 'implement feature',
});

const result = await handler.handle('parse_mode', {
prompt: 'ACT implement feature',
});

expect(result?.isError).toBeFalsy();
const parsed = JSON.parse((result?.content[0] as { text: string }).text);
expect(parsed.councilPreset).toBeUndefined();
});

it('should NOT include councilPreset in AUTO mode response', async () => {
mockKeywordService.parseMode = vi.fn().mockResolvedValue({
...mockParseModeResult,
mode: 'AUTO',
originalPrompt: 'implement dashboard',
});

const result = await handler.handle('parse_mode', {
prompt: 'AUTO implement dashboard',
});

expect(result?.isError).toBeFalsy();
const parsed = JSON.parse((result?.content[0] as { text: string }).text);
expect(parsed.councilPreset).toBeUndefined();
});

it('should not include councilSummary when no specialist results exist', async () => {
const result = await handler.handle('parse_mode', {
prompt: 'PLAN design feature',
});

expect(result?.isError).toBeFalsy();
const parsed = JSON.parse((result?.content[0] as { text: string }).text);
expect(parsed.councilSummary).toBeUndefined();
});

it('councilPreset should be serializable JSON', async () => {
const result = await handler.handle('parse_mode', {
prompt: 'PLAN design feature',
});

expect(result?.isError).toBeFalsy();
const text = (result?.content[0] as { text: string }).text;
// Round-trip serialization test
const parsed = JSON.parse(text);
const reserialized = JSON.stringify(parsed.councilPreset);
expect(JSON.parse(reserialized)).toEqual(parsed.councilPreset);
});
});
});
8 changes: 8 additions & 0 deletions apps/mcp-server/src/mcp/handlers/mode.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
import { buildVisualData, type AgentVisualInput } from '../../keyword/visual-data.builder';
import { isValidVerbosity } from '../../shared/verbosity.types';
import { AgentService } from '../../agent/agent.service';
import { CouncilPresetService } from '../../agent/council-preset.service';
import { ImpactEventService } from '../../impact';
import { RuleEventCollector } from '../../rules/rule-event-collector';

Expand Down Expand Up @@ -104,6 +105,7 @@ export class ModeHandler extends AbstractHandler {
private readonly contextDocService: ContextDocumentService,
private readonly diagnosticLogService: DiagnosticLogService,
private readonly agentService: AgentService,
private readonly councilPresetService: CouncilPresetService,
private readonly impactEventService: ImpactEventService,
private readonly ruleEventCollector: RuleEventCollector,
) {
Expand Down Expand Up @@ -276,6 +278,10 @@ export class ModeHandler extends AbstractHandler {
settings?.ai?.agentDiscussion,
);

// Resolve council preset for PLAN/EVAL modes
const councilPreset =
this.councilPresetService.resolvePreset(result.mode as Mode) ?? undefined;

// Build visual data for agent visualization
const visual = await this.buildVisual(
result.mode as Mode,
Expand All @@ -299,6 +305,8 @@ export class ModeHandler extends AbstractHandler {
...(agentDiscussion && { agentDiscussion }),
// Include visual data for agent visualization
...(visual && { visual }),
// Include council preset for PLAN/EVAL modes
...(councilPreset && { councilPreset }),
// Include context document info (mandatory)
...contextResult,
// Include project root warning when auto-detected and config missing
Expand Down
2 changes: 2 additions & 0 deletions apps/mcp-server/src/mcp/mcp.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { SkillRecommendationService } from '../skill/skill-recommendation.servic
import type { RecommendSkillsResult, ListSkillsResult } from '../skill/skill-recommendation.types';
import { LanguageService } from '../shared/language.service';
import { AgentService } from '../agent/agent.service';
import { CouncilPresetService } from '../agent/council-preset.service';
import type { AgentSystemPrompt, ParallelAgentSet } from '../agent/agent.types';
import { ChecklistService } from '../checklist/checklist.service';
import { ContextService } from '../context/context.service';
Expand Down Expand Up @@ -492,6 +493,7 @@ function createMcpServiceWithHandlers(
services.contextDocService as ContextDocumentService,
services.diagnosticLogService as DiagnosticLogService,
services.agentService as AgentService,
new CouncilPresetService(),
services.impactEventService as ImpactEventService,
services.ruleEventCollector as RuleEventCollector,
),
Expand Down
Loading