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
191 changes: 191 additions & 0 deletions apps/mcp-server/src/collaboration/council-summary.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { describe, it, expect } from 'vitest';
import { generateCouncilSummary } from './council-summary.service';
import { createAgentOpinion } from './types';
import type { CouncilInput } from './council-summary.types';

function successInput(
agentName: string,
stance: 'approve' | 'concern' | 'reject',
reasoning: string,
suggestedChanges: string[] = [],
): CouncilInput {
return {
agentName,
opinion: createAgentOpinion({
agentId: `specialist:${agentName.toLowerCase().replace(/\s+/g, '-')}`,
agentName,
stance,
reasoning,
suggestedChanges,
}),
};
}

function failedInput(agentName: string, error = 'Agent timed out'): CouncilInput {
return { agentName, opinion: null, error };
}

describe('generateCouncilSummary', () => {
describe('normal case: all specialists respond', () => {
it('should produce a full summary with opinions, consensus, and disagreements', () => {
const inputs: CouncilInput[] = [
successInput('Architecture Specialist', 'approve', 'Clean layered design'),
successInput('Security Specialist', 'reject', 'Missing input validation', [
'Add schema validation',
]),
successInput('Performance Specialist', 'approve', 'Bundle size within budget'),
];

const summary = generateCouncilSummary(inputs);

expect(summary.opinions).toHaveLength(3);
expect(summary.failedAgents).toHaveLength(0);
expect(summary.partialFailure).toBe(false);
expect(summary.disagreements.length).toBeGreaterThan(0);
expect(summary.blockingRisks.length).toBeGreaterThan(0);
expect(summary.nextStep).toBeTruthy();
});
});

describe('partial failure: 1+ specialist fails', () => {
it('should produce a degraded but useful summary', () => {
const inputs: CouncilInput[] = [
successInput('Architecture Specialist', 'approve', 'Looks good'),
failedInput('Security Specialist', 'Connection timeout'),
successInput('Performance Specialist', 'approve', 'No issues'),
];

const summary = generateCouncilSummary(inputs);

expect(summary.opinions).toHaveLength(2);
expect(summary.failedAgents).toEqual(['Security Specialist']);
expect(summary.partialFailure).toBe(true);
expect(summary.nextStep).toBeTruthy();
});

it('should produce summary even when all but one fail', () => {
const inputs: CouncilInput[] = [
successInput('Architecture Specialist', 'concern', 'Coupling risk'),
failedInput('Security Specialist'),
failedInput('Performance Specialist'),
];

const summary = generateCouncilSummary(inputs);

expect(summary.opinions).toHaveLength(1);
expect(summary.failedAgents).toHaveLength(2);
expect(summary.partialFailure).toBe(true);
expect(summary.nextStep).toBeTruthy();
});
});

describe('no disagreement: all agree', () => {
it('should return empty disagreements when all approve', () => {
const inputs: CouncilInput[] = [
successInput('Architecture Specialist', 'approve', 'Clean design'),
successInput('Security Specialist', 'approve', 'Auth is solid'),
successInput('Performance Specialist', 'approve', 'Fast enough'),
];

const summary = generateCouncilSummary(inputs);

expect(summary.disagreements).toEqual([]);
expect(summary.consensus.length).toBeGreaterThan(0);
expect(summary.blockingRisks).toEqual([]);
});

it('should return empty disagreements when all share the same non-approve stance', () => {
const inputs: CouncilInput[] = [
successInput('Architecture Specialist', 'concern', 'Coupling risk'),
successInput('Security Specialist', 'concern', 'Needs review'),
];

const summary = generateCouncilSummary(inputs);

expect(summary.disagreements).toEqual([]);
});
});

describe('blocker-heavy case', () => {
it('should collect all blocking risks from reject-stance agents', () => {
const inputs: CouncilInput[] = [
successInput('Architecture Specialist', 'reject', 'Circular dependency detected', [
'Break the cycle via interface',
]),
successInput('Security Specialist', 'reject', 'SQL injection vulnerability', [
'Use parameterized queries',
]),
successInput('Performance Specialist', 'concern', 'Could be faster'),
];

const summary = generateCouncilSummary(inputs);

expect(summary.blockingRisks).toHaveLength(2);
expect(summary.blockingRisks).toContain(
'Architecture Specialist: Circular dependency detected',
);
expect(summary.blockingRisks).toContain('Security Specialist: SQL injection vulnerability');
});
});

describe('nextStep', () => {
it('should always have a nextStep even on total failure', () => {
const inputs: CouncilInput[] = [
failedInput('Architecture Specialist'),
failedInput('Security Specialist'),
];

const summary = generateCouncilSummary(inputs);

expect(summary.nextStep).toBeTruthy();
expect(summary.opinions).toHaveLength(0);
expect(summary.partialFailure).toBe(true);
});

it('should recommend addressing blockers when blocking risks exist', () => {
const inputs: CouncilInput[] = [
successInput('Security Specialist', 'reject', 'Critical vulnerability found'),
];

const summary = generateCouncilSummary(inputs);

expect(summary.nextStep).toContain('block');
});

it('should recommend proceeding when consensus is reached', () => {
const inputs: CouncilInput[] = [
successInput('Architecture Specialist', 'approve', 'All good'),
successInput('Security Specialist', 'approve', 'Secure'),
];

const summary = generateCouncilSummary(inputs);

expect(summary.nextStep).toContain('Proceed');
});
});

describe('edge cases', () => {
it('should handle empty input', () => {
const summary = generateCouncilSummary([]);

expect(summary.opinions).toEqual([]);
expect(summary.failedAgents).toEqual([]);
expect(summary.consensus).toEqual([]);
expect(summary.disagreements).toEqual([]);
expect(summary.blockingRisks).toEqual([]);
expect(summary.nextStep).toBeTruthy();
expect(summary.partialFailure).toBe(false);
});

it('should include suggested changes in consensus when shared across agents', () => {
const inputs: CouncilInput[] = [
successInput('Architecture Specialist', 'approve', 'Good', ['Add logging']),
successInput('Security Specialist', 'approve', 'Good', ['Add logging']),
];

const summary = generateCouncilSummary(inputs);

expect(summary.consensus).toContain('Shared recommendation: Add logging');
});
});
});
174 changes: 174 additions & 0 deletions apps/mcp-server/src/collaboration/council-summary.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/**
* Council Summary Service — generates a structured consensus summary
* from multi-agent specialist outputs.
*
* Pure module: no side effects, no I/O.
* Handles partial failures gracefully — produces a degraded but useful
* summary even when some specialist calls fail.
*/
import type { AgentOpinion, Stance } from './types';
import type {
CouncilInput,
CouncilSummary,
Disagreement,
DisagreementPosition,
} from './council-summary.types';

/**
* Generates a council summary from specialist inputs.
* Tolerates partial failures: failed specialists are recorded in `failedAgents`
* and excluded from analysis, but the summary is still produced.
*/
export function generateCouncilSummary(inputs: readonly CouncilInput[]): CouncilSummary {
const opinions: AgentOpinion[] = [];
const failedAgents: string[] = [];

for (const input of inputs) {
if (input.opinion !== null) {
opinions.push(input.opinion);
} else {
failedAgents.push(input.agentName);
}
}

const partialFailure = failedAgents.length > 0;
const consensus = detectConsensus(opinions);
const disagreements = detectDisagreements(opinions);
const blockingRisks = extractBlockingRisks(opinions);
const nextStep = determineNextStep(opinions, failedAgents, blockingRisks);

return {
opinions,
failedAgents,
consensus,
disagreements,
blockingRisks,
nextStep,
partialFailure,
};
}

/**
* Detects consensus points: things all specialists agree on.
* - Unanimous stance → reported as consensus
* - Shared suggested changes across 2+ agents → reported
*/
function detectConsensus(opinions: readonly AgentOpinion[]): string[] {
if (opinions.length === 0) return [];

const points: string[] = [];
const stances = new Set(opinions.map(o => o.stance));

if (stances.size === 1) {
const stance = opinions[0].stance;
points.push(`All specialists ${stanceVerb(stance)}`);
}

const sharedChanges = findSharedSuggestedChanges(opinions);
for (const change of sharedChanges) {
points.push(`Shared recommendation: ${change}`);
}

return points;
}

/** Returns suggested changes that appear in 2+ opinions. */
function findSharedSuggestedChanges(opinions: readonly AgentOpinion[]): string[] {
const changeCounts = new Map<string, number>();
for (const opinion of opinions) {
for (const change of opinion.suggestedChanges) {
changeCounts.set(change, (changeCounts.get(change) ?? 0) + 1);
}
}

const shared: string[] = [];
for (const [change, count] of changeCounts) {
if (count >= 2) {
shared.push(change);
}
}
return shared;
}

/** Human-readable verb for a stance. */
function stanceVerb(stance: Stance): string {
switch (stance) {
case 'approve':
return 'approve';
case 'concern':
return 'express concerns';
case 'reject':
return 'reject';
}
}

/**
* Detects disagreements: points where specialists diverge in stance.
* Groups by the distinct stance sets — if all share the same stance, no disagreement.
*/
function detectDisagreements(opinions: readonly AgentOpinion[]): Disagreement[] {
if (opinions.length < 2) return [];

const stances = new Set(opinions.map(o => o.stance));
if (stances.size <= 1) return [];

const positions: DisagreementPosition[] = opinions.map(o => ({
agentName: o.agentName,
stance: o.stance,
reasoning: o.reasoning,
}));

return [
{
topic: 'Overall assessment',
positions,
},
];
}

/**
* Extracts blocking risks from agents with reject stance.
* Format: "AgentName: reasoning"
*/
function extractBlockingRisks(opinions: readonly AgentOpinion[]): string[] {
return opinions.filter(o => o.stance === 'reject').map(o => `${o.agentName}: ${o.reasoning}`);
}

/**
* Determines the recommended next action based on the council state.
* Priority: blocking risks > partial failure > concerns > proceed.
*/
function determineNextStep(
opinions: readonly AgentOpinion[],
failedAgents: readonly string[],
blockingRisks: readonly string[],
): string {
if (opinions.length === 0 && failedAgents.length === 0) {
return 'No specialist input available. Run specialist analysis first.';
}

if (opinions.length === 0 && failedAgents.length > 0) {
return `Re-run failed specialists (${failedAgents.join(', ')}) to obtain analysis.`;
}

if (blockingRisks.length > 0) {
return `Address ${blockingRisks.length} blocking risk(s) before proceeding.`;
}

const concerns = opinions.filter(o => o.stance === 'concern');
const parts: string[] = [];

if (failedAgents.length > 0) {
parts.push(`re-run failed specialists (${failedAgents.join(', ')})`);
}

if (concerns.length > 0) {
parts.push(`review ${concerns.length} concern(s)`);
}

if (parts.length > 0) {
return `Proceed with caution: ${parts.join(', ')}.`;
}

return 'Proceed with implementation — all specialists approve.';
}
Loading
Loading