diff --git a/apps/mcp-server/src/collaboration/discussion-engine.spec.ts b/apps/mcp-server/src/collaboration/discussion-engine.spec.ts new file mode 100644 index 0000000..25fe0c5 --- /dev/null +++ b/apps/mcp-server/src/collaboration/discussion-engine.spec.ts @@ -0,0 +1,235 @@ +import { describe, it, expect } from 'vitest'; +import { DiscussionEngine } from './discussion-engine'; +import { createAgentOpinion, createCrossReview } from './types'; + +describe('DiscussionEngine', () => { + function createEngine(): DiscussionEngine { + return new DiscussionEngine(); + } + + describe('addOpinion', () => { + it('should add opinion to current round', () => { + const engine = createEngine(); + const opinion = createAgentOpinion({ + agentId: 'arch-1', + agentName: 'Architecture Specialist', + stance: 'approve', + reasoning: 'Clean design', + }); + + engine.addOpinion(opinion); + + const round = engine.getCurrentRound(); + expect(round.opinions).toHaveLength(1); + expect(round.opinions[0].agentId).toBe('arch-1'); + }); + + it('should accumulate multiple opinions in same round', () => { + const engine = createEngine(); + + engine.addOpinion( + createAgentOpinion({ + agentId: 'arch-1', + agentName: 'Architect', + stance: 'approve', + reasoning: 'ok', + }), + ); + engine.addOpinion( + createAgentOpinion({ + agentId: 'sec-1', + agentName: 'Security', + stance: 'reject', + reasoning: 'no', + }), + ); + + const round = engine.getCurrentRound(); + expect(round.opinions).toHaveLength(2); + }); + }); + + describe('addCrossReview', () => { + it('should add cross-review to current round', () => { + const engine = createEngine(); + + engine.addOpinion( + createAgentOpinion({ + agentId: 'arch-1', + agentName: 'Architect', + stance: 'concern', + reasoning: 'Circular dep risk', + }), + ); + + engine.addCrossReview( + createCrossReview({ + fromAgentId: 'arch-1', + toAgentId: 'sec-1', + stance: 'approve', + comment: 'Middleware solves it', + }), + ); + + const round = engine.getCurrentRound(); + expect(round.crossReviews).toHaveLength(1); + expect(round.crossReviews[0].fromAgentId).toBe('arch-1'); + }); + }); + + describe('nextRound', () => { + it('should advance to a new round', () => { + const engine = createEngine(); + + engine.addOpinion( + createAgentOpinion({ + agentId: 'a1', + agentName: 'A1', + stance: 'approve', + reasoning: 'ok', + }), + ); + + engine.nextRound(); + + const round = engine.getCurrentRound(); + expect(round.roundNumber).toBe(2); + expect(round.opinions).toHaveLength(0); + }); + + it('should preserve previous rounds', () => { + const engine = createEngine(); + + engine.addOpinion( + createAgentOpinion({ + agentId: 'a1', + agentName: 'A1', + stance: 'approve', + reasoning: 'ok', + }), + ); + engine.nextRound(); + + const allRounds = engine.getAllRounds(); + expect(allRounds).toHaveLength(2); + expect(allRounds[0].opinions).toHaveLength(1); + expect(allRounds[1].opinions).toHaveLength(0); + }); + }); + + describe('getConsensus', () => { + it('should calculate consensus for current round', () => { + const engine = createEngine(); + + engine.addOpinion( + createAgentOpinion({ + agentId: 'a1', + agentName: 'A1', + stance: 'approve', + reasoning: 'ok', + }), + ); + engine.addOpinion( + createAgentOpinion({ + agentId: 'a2', + agentName: 'A2', + stance: 'approve', + reasoning: 'ok', + }), + ); + engine.addOpinion( + createAgentOpinion({ + agentId: 'a3', + agentName: 'A3', + stance: 'concern', + reasoning: 'watch', + }), + ); + + const consensus = engine.getConsensus(); + expect(consensus.totalAgents).toBe(3); + expect(consensus.approveCount).toBe(2); + expect(consensus.concernCount).toBe(1); + expect(consensus.reached).toBe(true); + }); + + it('should not reach consensus with rejections', () => { + const engine = createEngine(); + + engine.addOpinion( + createAgentOpinion({ + agentId: 'a1', + agentName: 'A1', + stance: 'approve', + reasoning: 'ok', + }), + ); + engine.addOpinion( + createAgentOpinion({ + agentId: 'a2', + agentName: 'A2', + stance: 'reject', + reasoning: 'no', + }), + ); + + const consensus = engine.getConsensus(); + expect(consensus.reached).toBe(false); + expect(consensus.criticalCount).toBe(1); + }); + }); + + describe('getAgentStanceHistory', () => { + it('should track stance changes across rounds', () => { + const engine = createEngine(); + + engine.addOpinion( + createAgentOpinion({ + agentId: 'a1', + agentName: 'Architect', + stance: 'concern', + reasoning: 'risky', + }), + ); + engine.nextRound(); + engine.addOpinion( + createAgentOpinion({ + agentId: 'a1', + agentName: 'Architect', + stance: 'approve', + reasoning: 'revised approach works', + }), + ); + + const history = engine.getAgentStanceHistory('a1'); + expect(history).toEqual(['concern', 'approve']); + }); + + it('should return empty for unknown agent', () => { + const engine = createEngine(); + expect(engine.getAgentStanceHistory('unknown')).toEqual([]); + }); + }); + + describe('reset', () => { + it('should clear all rounds and start fresh', () => { + const engine = createEngine(); + + engine.addOpinion( + createAgentOpinion({ + agentId: 'a1', + agentName: 'A1', + stance: 'approve', + reasoning: 'ok', + }), + ); + engine.nextRound(); + + engine.reset(); + + expect(engine.getAllRounds()).toHaveLength(1); + expect(engine.getCurrentRound().roundNumber).toBe(1); + expect(engine.getCurrentRound().opinions).toHaveLength(0); + }); + }); +}); diff --git a/apps/mcp-server/src/collaboration/discussion-engine.ts b/apps/mcp-server/src/collaboration/discussion-engine.ts new file mode 100644 index 0000000..cceafb5 --- /dev/null +++ b/apps/mcp-server/src/collaboration/discussion-engine.ts @@ -0,0 +1,68 @@ +/** + * Discussion Engine — tracks agent opinions, cross-reviews, and consensus + * across multiple rounds of structured debate. + */ +import type { AgentOpinion, CrossReview, ConsensusResult, DiscussionRound, Stance } from './types'; +import { calculateConsensus } from './types'; + +export class DiscussionEngine { + private rounds: DiscussionRound[] = []; + + constructor() { + this.rounds.push({ roundNumber: 1, opinions: [], crossReviews: [] }); + } + + /** Add an agent opinion to the current round. */ + addOpinion(opinion: AgentOpinion): void { + const current = this.mutableCurrentRound(); + (current.opinions as AgentOpinion[]).push(opinion); + } + + /** Add a cross-review to the current round. */ + addCrossReview(review: CrossReview): void { + const current = this.mutableCurrentRound(); + (current.crossReviews as CrossReview[]).push(review); + } + + /** Advance to the next discussion round. */ + nextRound(): void { + const nextNumber = this.rounds.length + 1; + this.rounds.push({ roundNumber: nextNumber, opinions: [], crossReviews: [] }); + } + + /** Get the current (latest) discussion round. */ + getCurrentRound(): DiscussionRound { + return this.rounds[this.rounds.length - 1]; + } + + /** Get all discussion rounds. */ + getAllRounds(): readonly DiscussionRound[] { + return this.rounds; + } + + /** Calculate consensus for the current round's opinions. */ + getConsensus(): ConsensusResult { + return calculateConsensus(this.getCurrentRound().opinions); + } + + /** Track an agent's stance changes across all rounds. */ + getAgentStanceHistory(agentId: string): Stance[] { + const stances: Stance[] = []; + for (const round of this.rounds) { + const opinion = round.opinions.find(o => o.agentId === agentId); + if (opinion) { + stances.push(opinion.stance); + } + } + return stances; + } + + /** Reset the engine, clearing all rounds. */ + reset(): void { + this.rounds = [{ roundNumber: 1, opinions: [], crossReviews: [] }]; + } + + private mutableCurrentRound(): DiscussionRound { + return this.rounds[this.rounds.length - 1]; + } +} diff --git a/apps/mcp-server/src/collaboration/index.ts b/apps/mcp-server/src/collaboration/index.ts new file mode 100644 index 0000000..c5f3227 --- /dev/null +++ b/apps/mcp-server/src/collaboration/index.ts @@ -0,0 +1,27 @@ +/** + * Collaboration module — public API + */ +export type { + Stance, + AgentOpinion, + CrossReview, + DiscussionRound, + ConsensusResult, + CreateAgentOpinionParams, + CreateCrossReviewParams, +} from './types'; +export { + STANCES, + STANCE_ICONS, + createAgentOpinion, + createCrossReview, + createDiscussionRound, + calculateConsensus, +} from './types'; +export { DiscussionEngine } from './discussion-engine'; +export { + formatOpinion, + formatCrossReview, + formatConsensus, + formatDiscussionRound, +} from './terminal-formatter'; diff --git a/apps/mcp-server/src/collaboration/terminal-formatter.spec.ts b/apps/mcp-server/src/collaboration/terminal-formatter.spec.ts new file mode 100644 index 0000000..275a1c2 --- /dev/null +++ b/apps/mcp-server/src/collaboration/terminal-formatter.spec.ts @@ -0,0 +1,243 @@ +import { describe, it, expect } from 'vitest'; +import { + formatOpinion, + formatCrossReview, + formatConsensus, + formatDiscussionRound, +} from './terminal-formatter'; +import { createAgentOpinion, createCrossReview } from './types'; +import type { AgentOpinion, CrossReview, ConsensusResult, DiscussionRound } from './types'; + +describe('terminal-formatter', () => { + describe('formatOpinion', () => { + it('should format an approve opinion with emoji and stance', () => { + const opinion = createAgentOpinion({ + agentId: 'arch-1', + agentName: 'architecture', + stance: 'approve', + reasoning: 'Clean separation of concerns', + }); + + const result = formatOpinion(opinion); + + expect(result).toContain('architecture'); + expect(result).toContain('✅'); + expect(result).toContain('approve'); + expect(result).toContain('Clean separation of concerns'); + }); + + it('should format a concern opinion', () => { + const opinion = createAgentOpinion({ + agentId: 'arch-1', + agentName: 'architecture', + stance: 'concern', + reasoning: 'Circular dependency risk', + }); + + const result = formatOpinion(opinion); + + expect(result).toContain('⚠️'); + expect(result).toContain('concern'); + expect(result).toContain('Circular dependency risk'); + }); + + it('should format a reject opinion', () => { + const opinion = createAgentOpinion({ + agentId: 'sec-1', + agentName: 'security', + stance: 'reject', + reasoning: 'Separation breaks auth context', + }); + + const result = formatOpinion(opinion); + + expect(result).toContain('❌'); + expect(result).toContain('reject'); + expect(result).toContain('Separation breaks auth context'); + }); + + it('should include suggested changes when present', () => { + const opinion = createAgentOpinion({ + agentId: 'sec-1', + agentName: 'security', + stance: 'reject', + reasoning: 'Auth breaks', + suggestedChanges: ['Add middleware isolation'], + }); + + const result = formatOpinion(opinion); + + expect(result).toContain('Add middleware isolation'); + }); + }); + + describe('formatCrossReview', () => { + it('should format a cross-review with arrow notation', () => { + const review = createCrossReview({ + fromAgentId: 'arch-1', + toAgentId: 'sec-1', + stance: 'approve', + comment: 'Middleware + interface segregation', + }); + + const agentNames: Record = { + 'arch-1': 'architecture', + 'sec-1': 'security', + }; + + const result = formatCrossReview(review, agentNames); + + expect(result).toContain('architecture'); + expect(result).toContain('→'); + expect(result).toContain('security'); + expect(result).toContain('agrees'); + expect(result).toContain('Middleware + interface segregation'); + }); + + it('should show "disagrees" for reject cross-review', () => { + const review = createCrossReview({ + fromAgentId: 'sec-1', + toAgentId: 'arch-1', + stance: 'reject', + comment: 'Not sufficient', + }); + + const agentNames: Record = { + 'arch-1': 'architecture', + 'sec-1': 'security', + }; + + const result = formatCrossReview(review, agentNames); + + expect(result).toContain('disagrees'); + }); + + it('should show "notes" for concern cross-review', () => { + const review = createCrossReview({ + fromAgentId: 'test-1', + toAgentId: 'arch-1', + stance: 'concern', + comment: 'Edge cases missing', + }); + + const agentNames: Record = { + 'test-1': 'test-strategy', + 'arch-1': 'architecture', + }; + + const result = formatCrossReview(review, agentNames); + + expect(result).toContain('notes'); + }); + }); + + describe('formatConsensus', () => { + it('should format full consensus', () => { + const consensus: ConsensusResult = { + totalAgents: 4, + approveCount: 4, + concernCount: 0, + rejectCount: 0, + reached: true, + criticalCount: 0, + }; + + const result = formatConsensus(consensus); + + expect(result).toContain('✅'); + expect(result).toContain('Consensus'); + expect(result).toContain('4/4'); + expect(result).toContain('Critical: 0'); + }); + + it('should format failed consensus with critical count', () => { + const consensus: ConsensusResult = { + totalAgents: 3, + approveCount: 1, + concernCount: 1, + rejectCount: 1, + reached: false, + criticalCount: 1, + }; + + const result = formatConsensus(consensus); + + expect(result).toContain('❌'); + expect(result).toContain('1/3'); + expect(result).toContain('Critical: 1'); + }); + }); + + describe('formatDiscussionRound', () => { + it('should format a complete round with opinions, cross-reviews, and consensus', () => { + const opinions: AgentOpinion[] = [ + createAgentOpinion({ + agentId: 'arch-1', + agentName: 'architecture', + stance: 'concern', + reasoning: 'Circular dependency risk', + }), + createAgentOpinion({ + agentId: 'sec-1', + agentName: 'security', + stance: 'reject', + reasoning: 'Separation breaks auth context', + }), + createAgentOpinion({ + agentId: 'test-1', + agentName: 'test-strategy', + stance: 'approve', + reasoning: 'Good testability', + }), + ]; + + const crossReviews: CrossReview[] = [ + createCrossReview({ + fromAgentId: 'arch-1', + toAgentId: 'sec-1', + stance: 'approve', + comment: 'Middleware + interface segregation', + }), + ]; + + const round: DiscussionRound = { + roundNumber: 1, + opinions, + crossReviews, + }; + + const result = formatDiscussionRound(round); + + // Should contain all opinions + expect(result).toContain('architecture'); + expect(result).toContain('security'); + expect(result).toContain('test-strategy'); + // Should contain cross-review + expect(result).toContain('→'); + // Should contain consensus line + expect(result).toContain('Consensus'); + expect(result).toContain('Critical: 1'); + }); + + it('should handle round with no cross-reviews', () => { + const round: DiscussionRound = { + roundNumber: 1, + opinions: [ + createAgentOpinion({ + agentId: 'a1', + agentName: 'architect', + stance: 'approve', + reasoning: 'ok', + }), + ], + crossReviews: [], + }; + + const result = formatDiscussionRound(round); + + expect(result).toContain('architect'); + expect(result).toContain('Consensus'); + expect(result).not.toContain('→'); + }); + }); +}); diff --git a/apps/mcp-server/src/collaboration/terminal-formatter.ts b/apps/mcp-server/src/collaboration/terminal-formatter.ts new file mode 100644 index 0000000..dce163a --- /dev/null +++ b/apps/mcp-server/src/collaboration/terminal-formatter.ts @@ -0,0 +1,76 @@ +/** + * Terminal Formatter — renders agent debates as colored markdown for terminal output. + * + * Uses agent emoji avatars from theme and stance icons from types. + */ +import type { AgentOpinion, CrossReview, ConsensusResult, DiscussionRound, Stance } from './types'; +import { STANCE_ICONS, calculateConsensus } from './types'; +import { getAgentAvatar } from '../tui/utils/theme'; + +/** Map stance to cross-review verb. */ +const CROSS_REVIEW_VERBS: Readonly> = { + approve: 'agrees', + concern: 'notes', + reject: 'disagrees', +}; + +/** Format a single agent opinion as a terminal line. */ +export function formatOpinion(opinion: AgentOpinion): string { + const avatar = getAgentAvatar(opinion.agentName); + const icon = STANCE_ICONS[opinion.stance]; + const parts = [ + `${avatar} ${opinion.agentName} — ${icon} ${opinion.stance}: "${opinion.reasoning}"`, + ]; + + if (opinion.suggestedChanges.length > 0) { + for (const change of opinion.suggestedChanges) { + parts.push(` → ${change}`); + } + } + + return parts.join('\n'); +} + +/** Format a cross-review as a terminal line with arrow notation. */ +export function formatCrossReview(review: CrossReview, agentNames: Record): string { + const fromAvatar = getAgentAvatar(agentNames[review.fromAgentId] ?? 'unknown'); + const fromName = agentNames[review.fromAgentId] ?? review.fromAgentId; + const toName = agentNames[review.toAgentId] ?? review.toAgentId; + const verb = CROSS_REVIEW_VERBS[review.stance]; + + return `${fromAvatar} ${fromName} → ${toName} ${verb}: "${review.comment}"`; +} + +/** Format consensus result as a terminal line. */ +export function formatConsensus(consensus: ConsensusResult): string { + const icon = consensus.reached ? '✅' : '❌'; + const agreeCount = consensus.approveCount; + return `${icon} Consensus: ${agreeCount}/${consensus.totalAgents} | Critical: ${consensus.criticalCount}`; +} + +/** Format a complete discussion round with opinions, cross-reviews, and consensus. */ +export function formatDiscussionRound(round: DiscussionRound): string { + const lines: string[] = []; + + // Opinions + for (const opinion of round.opinions) { + lines.push(formatOpinion(opinion)); + } + + // Cross-reviews + if (round.crossReviews.length > 0) { + const agentNames: Record = {}; + for (const opinion of round.opinions) { + agentNames[opinion.agentId] = opinion.agentName; + } + for (const review of round.crossReviews) { + lines.push(formatCrossReview(review, agentNames)); + } + } + + // Consensus + const consensus = calculateConsensus(round.opinions); + lines.push(formatConsensus(consensus)); + + return lines.join('\n'); +} diff --git a/apps/mcp-server/src/collaboration/types.spec.ts b/apps/mcp-server/src/collaboration/types.spec.ts new file mode 100644 index 0000000..5ade382 --- /dev/null +++ b/apps/mcp-server/src/collaboration/types.spec.ts @@ -0,0 +1,195 @@ +import { describe, it, expect } from 'vitest'; +import { + type AgentOpinion, + type CrossReview, + STANCES, + createAgentOpinion, + createCrossReview, + createDiscussionRound, + calculateConsensus, +} from './types'; + +describe('collaboration/types', () => { + describe('STANCES', () => { + it('should contain approve, concern, and reject', () => { + expect(STANCES).toEqual(['approve', 'concern', 'reject']); + }); + }); + + describe('createAgentOpinion', () => { + it('should create an opinion with required fields', () => { + const opinion = createAgentOpinion({ + agentId: 'arch-1', + agentName: 'Architecture Specialist', + stance: 'approve', + reasoning: 'Clean separation of concerns', + }); + + expect(opinion.agentId).toBe('arch-1'); + expect(opinion.agentName).toBe('Architecture Specialist'); + expect(opinion.stance).toBe('approve'); + expect(opinion.reasoning).toBe('Clean separation of concerns'); + expect(opinion.suggestedChanges).toEqual([]); + expect(opinion.timestamp).toBeGreaterThan(0); + }); + + it('should include suggested changes when provided', () => { + const opinion = createAgentOpinion({ + agentId: 'sec-1', + agentName: 'Security Specialist', + stance: 'reject', + reasoning: 'Auth context breaks', + suggestedChanges: ['Add middleware isolation', 'Use interface segregation'], + }); + + expect(opinion.suggestedChanges).toEqual([ + 'Add middleware isolation', + 'Use interface segregation', + ]); + }); + + it('should accept custom timestamp', () => { + const opinion = createAgentOpinion({ + agentId: 'test-1', + agentName: 'Test Engineer', + stance: 'concern', + reasoning: 'Missing edge cases', + timestamp: 1000, + }); + + expect(opinion.timestamp).toBe(1000); + }); + }); + + describe('createCrossReview', () => { + it('should create a cross-review between two agents', () => { + const review = createCrossReview({ + fromAgentId: 'arch-1', + toAgentId: 'sec-1', + stance: 'approve', + comment: 'Middleware + interface segregation works', + }); + + expect(review.fromAgentId).toBe('arch-1'); + expect(review.toAgentId).toBe('sec-1'); + expect(review.stance).toBe('approve'); + expect(review.comment).toBe('Middleware + interface segregation works'); + expect(review.timestamp).toBeGreaterThan(0); + }); + }); + + describe('createDiscussionRound', () => { + it('should create a round with opinions and empty cross-reviews', () => { + const opinions: AgentOpinion[] = [ + createAgentOpinion({ + agentId: 'arch-1', + agentName: 'Architecture Specialist', + stance: 'approve', + reasoning: 'Looks good', + }), + ]; + + const round = createDiscussionRound(1, opinions); + + expect(round.roundNumber).toBe(1); + expect(round.opinions).toHaveLength(1); + expect(round.crossReviews).toEqual([]); + }); + + it('should include cross-reviews when provided', () => { + const opinions: AgentOpinion[] = [ + createAgentOpinion({ + agentId: 'arch-1', + agentName: 'Architect', + stance: 'concern', + reasoning: 'Circular dep risk', + }), + createAgentOpinion({ + agentId: 'sec-1', + agentName: 'Security', + stance: 'reject', + reasoning: 'Auth context breaks', + }), + ]; + + const crossReviews: CrossReview[] = [ + createCrossReview({ + fromAgentId: 'arch-1', + toAgentId: 'sec-1', + stance: 'approve', + comment: 'Middleware solves it', + }), + ]; + + const round = createDiscussionRound(2, opinions, crossReviews); + + expect(round.roundNumber).toBe(2); + expect(round.opinions).toHaveLength(2); + expect(round.crossReviews).toHaveLength(1); + }); + }); + + describe('calculateConsensus', () => { + it('should return full consensus when all approve', () => { + const opinions: AgentOpinion[] = [ + createAgentOpinion({ agentId: 'a1', agentName: 'A1', stance: 'approve', reasoning: 'ok' }), + createAgentOpinion({ agentId: 'a2', agentName: 'A2', stance: 'approve', reasoning: 'ok' }), + createAgentOpinion({ agentId: 'a3', agentName: 'A3', stance: 'approve', reasoning: 'ok' }), + ]; + + const result = calculateConsensus(opinions); + + expect(result.totalAgents).toBe(3); + expect(result.approveCount).toBe(3); + expect(result.concernCount).toBe(0); + expect(result.rejectCount).toBe(0); + expect(result.reached).toBe(true); + expect(result.criticalCount).toBe(0); + }); + + it('should not reach consensus when there are rejections', () => { + const opinions: AgentOpinion[] = [ + createAgentOpinion({ agentId: 'a1', agentName: 'A1', stance: 'approve', reasoning: 'ok' }), + createAgentOpinion({ agentId: 'a2', agentName: 'A2', stance: 'reject', reasoning: 'no' }), + createAgentOpinion({ agentId: 'a3', agentName: 'A3', stance: 'approve', reasoning: 'ok' }), + ]; + + const result = calculateConsensus(opinions); + + expect(result.totalAgents).toBe(3); + expect(result.approveCount).toBe(2); + expect(result.rejectCount).toBe(1); + expect(result.reached).toBe(false); + expect(result.criticalCount).toBe(1); + }); + + it('should reach consensus with concerns but no rejections', () => { + const opinions: AgentOpinion[] = [ + createAgentOpinion({ agentId: 'a1', agentName: 'A1', stance: 'approve', reasoning: 'ok' }), + createAgentOpinion({ + agentId: 'a2', + agentName: 'A2', + stance: 'concern', + reasoning: 'watch out', + }), + ]; + + const result = calculateConsensus(opinions); + + expect(result.totalAgents).toBe(2); + expect(result.approveCount).toBe(1); + expect(result.concernCount).toBe(1); + expect(result.rejectCount).toBe(0); + expect(result.reached).toBe(true); + expect(result.criticalCount).toBe(0); + }); + + it('should handle empty opinions', () => { + const result = calculateConsensus([]); + + expect(result.totalAgents).toBe(0); + expect(result.reached).toBe(false); + expect(result.criticalCount).toBe(0); + }); + }); +}); diff --git a/apps/mcp-server/src/collaboration/types.ts b/apps/mcp-server/src/collaboration/types.ts new file mode 100644 index 0000000..211f046 --- /dev/null +++ b/apps/mcp-server/src/collaboration/types.ts @@ -0,0 +1,130 @@ +/** + * Agent Collaboration Visualization - Type Definitions + * + * Types for structured agent debates: opinions, cross-reviews, + * discussion rounds, and consensus calculation. + */ + +/** Agent stance on a proposal or review item. */ +export type Stance = 'approve' | 'concern' | 'reject'; + +/** Ordered list of valid stances. */ +export const STANCES: readonly Stance[] = Object.freeze(['approve', 'concern', 'reject']); + +/** Emoji icons for each stance. */ +export const STANCE_ICONS: Readonly> = Object.freeze({ + approve: '✅', + concern: '⚠️', + reject: '❌', +}); + +/** A single agent's opinion in a discussion round. */ +export interface AgentOpinion { + readonly agentId: string; + readonly agentName: string; + readonly stance: Stance; + readonly reasoning: string; + readonly suggestedChanges: readonly string[]; + readonly timestamp: number; +} + +/** Parameters for creating an AgentOpinion. */ +export interface CreateAgentOpinionParams { + agentId: string; + agentName: string; + stance: Stance; + reasoning: string; + suggestedChanges?: string[]; + timestamp?: number; +} + +/** Create an AgentOpinion with defaults for optional fields. */ +export function createAgentOpinion(params: CreateAgentOpinionParams): AgentOpinion { + return { + agentId: params.agentId, + agentName: params.agentName, + stance: params.stance, + reasoning: params.reasoning, + suggestedChanges: params.suggestedChanges ?? [], + timestamp: params.timestamp ?? Date.now(), + }; +} + +/** A cross-review: one agent commenting on another's opinion. */ +export interface CrossReview { + readonly fromAgentId: string; + readonly toAgentId: string; + readonly stance: Stance; + readonly comment: string; + readonly timestamp: number; +} + +/** Parameters for creating a CrossReview. */ +export interface CreateCrossReviewParams { + fromAgentId: string; + toAgentId: string; + stance: Stance; + comment: string; + timestamp?: number; +} + +/** Create a CrossReview with defaults for optional fields. */ +export function createCrossReview(params: CreateCrossReviewParams): CrossReview { + return { + fromAgentId: params.fromAgentId, + toAgentId: params.toAgentId, + stance: params.stance, + comment: params.comment, + timestamp: params.timestamp ?? Date.now(), + }; +} + +/** A single round of discussion containing opinions and cross-reviews. */ +export interface DiscussionRound { + readonly roundNumber: number; + readonly opinions: readonly AgentOpinion[]; + readonly crossReviews: readonly CrossReview[]; +} + +/** Create a DiscussionRound. */ +export function createDiscussionRound( + roundNumber: number, + opinions: AgentOpinion[], + crossReviews: CrossReview[] = [], +): DiscussionRound { + return { + roundNumber, + opinions, + crossReviews, + }; +} + +/** Result of consensus calculation across agent opinions. */ +export interface ConsensusResult { + readonly totalAgents: number; + readonly approveCount: number; + readonly concernCount: number; + readonly rejectCount: number; + readonly reached: boolean; + readonly criticalCount: number; +} + +/** + * Calculate consensus from a set of opinions. + * Consensus is reached when there are no rejections and at least one opinion. + * Critical count = number of rejections. + */ +export function calculateConsensus(opinions: readonly AgentOpinion[]): ConsensusResult { + const approveCount = opinions.filter(o => o.stance === 'approve').length; + const concernCount = opinions.filter(o => o.stance === 'concern').length; + const rejectCount = opinions.filter(o => o.stance === 'reject').length; + + return { + totalAgents: opinions.length, + approveCount, + concernCount, + rejectCount, + reached: opinions.length > 0 && rejectCount === 0, + criticalCount: rejectCount, + }; +} diff --git a/apps/mcp-server/src/tui/components/AgentDiscussionPanel.spec.tsx b/apps/mcp-server/src/tui/components/AgentDiscussionPanel.spec.tsx new file mode 100644 index 0000000..b6a6dbc --- /dev/null +++ b/apps/mcp-server/src/tui/components/AgentDiscussionPanel.spec.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { render } from 'ink-testing-library'; +import { AgentDiscussionPanel } from './AgentDiscussionPanel'; +import { createAgentOpinion, createCrossReview } from '../../collaboration/types'; +import type { DiscussionRound } from '../../collaboration/types'; + +function makeRound(): DiscussionRound { + return { + roundNumber: 1, + opinions: [ + createAgentOpinion({ + agentId: 'arch-1', + agentName: 'architecture', + stance: 'approve', + reasoning: 'Clean design', + }), + createAgentOpinion({ + agentId: 'sec-1', + agentName: 'security', + stance: 'concern', + reasoning: 'Auth needs review', + }), + createAgentOpinion({ + agentId: 'test-1', + agentName: 'test-strategy', + stance: 'approve', + reasoning: 'Good testability', + }), + ], + crossReviews: [ + createCrossReview({ + fromAgentId: 'arch-1', + toAgentId: 'sec-1', + stance: 'approve', + comment: 'Middleware pattern solves it', + }), + ], + }; +} + +describe('tui/components/AgentDiscussionPanel', () => { + it('should render discussion with agent opinions', () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Agent Discussion'); + expect(frame).toContain('architecture'); + expect(frame).toContain('security'); + expect(frame).toContain('test-strategy'); + }); + + it('should render consensus line', () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Consensus'); + expect(frame).toContain('2/3'); + }); + + it('should render cross-review with arrow', () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('→'); + expect(frame).toContain('agrees'); + }); + + it('should render empty state when no rounds', () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('No agent discussion yet'); + }); + + it('should truncate lines to fit height', () => { + const manyOpinions = Array.from({ length: 20 }, (_, i) => + createAgentOpinion({ + agentId: `a${i}`, + agentName: `agent-${i}`, + stance: 'approve', + reasoning: `Reason ${i}`, + }), + ); + + const round: DiscussionRound = { + roundNumber: 1, + opinions: manyOpinions, + crossReviews: [], + }; + + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ''; + // Should not contain all 20 agents due to height constraint + const lines = frame.split('\n'); + // Border + content should fit within height + expect(lines.length).toBeLessThanOrEqual(10); // some tolerance for border rendering + }); +}); diff --git a/apps/mcp-server/src/tui/components/AgentDiscussionPanel.tsx b/apps/mcp-server/src/tui/components/AgentDiscussionPanel.tsx new file mode 100644 index 0000000..2bf02b1 --- /dev/null +++ b/apps/mcp-server/src/tui/components/AgentDiscussionPanel.tsx @@ -0,0 +1,67 @@ +/** + * Agent Discussion Panel — Ink/React TUI component. + * + * Replaces FlowMap to visualize agent collaboration as structured debates. + * Shows agent opinions, cross-reviews, stance changes, and consensus status. + */ +import React, { useMemo } from 'react'; +import { Box, Text } from 'ink'; +import type { DiscussionRound } from '../../collaboration/types'; +import { BORDER_COLORS } from '../utils/theme'; +import { renderDiscussionPanel, type DiscussionLine } from './agent-discussion-panel.pure'; + +export interface AgentDiscussionPanelProps { + rounds: readonly DiscussionRound[]; + width: number; + height: number; +} + +/** Color map for discussion line types. */ +const LINE_COLORS: Record = { + opinion: 'white', + 'cross-review': 'cyan', + consensus: 'green', + header: 'magenta', + empty: 'gray', +}; + +/** + * Agent Discussion Panel — renders structured agent debates in the TUI dashboard. + * Replaces the FlowMap component in the dashboard layout. + */ +export function AgentDiscussionPanel({ + rounds, + width, + height, +}: AgentDiscussionPanelProps): React.ReactElement { + const lines = useMemo( + () => renderDiscussionPanel(rounds, width), + [rounds, width], + ); + + // Limit lines to available height (minus 2 for border) + const maxLines = Math.max(0, height - 2); + const visibleLines = lines.slice(0, maxLines); + + return ( + + {visibleLines.map((line, i) => ( + + {line.text} + + ))} + + ); +} diff --git a/apps/mcp-server/src/tui/components/agent-discussion-panel.pure.spec.ts b/apps/mcp-server/src/tui/components/agent-discussion-panel.pure.spec.ts new file mode 100644 index 0000000..3c1dff1 --- /dev/null +++ b/apps/mcp-server/src/tui/components/agent-discussion-panel.pure.spec.ts @@ -0,0 +1,199 @@ +import { describe, it, expect } from 'vitest'; +import { + renderOpinionLine, + renderCrossReviewLine, + renderConsensusLine, + renderDiscussionPanel, + renderStanceHistory, +} from './agent-discussion-panel.pure'; +import { createAgentOpinion, createCrossReview } from '../../collaboration/types'; +import type { DiscussionRound, ConsensusResult } from '../../collaboration/types'; + +describe('agent-discussion-panel.pure', () => { + describe('renderStanceHistory', () => { + it('should return empty for single stance', () => { + expect(renderStanceHistory(['approve'])).toBe(''); + }); + + it('should render arrow-separated icons for multiple stances', () => { + const result = renderStanceHistory(['concern', 'approve']); + expect(result).toContain('⚠️'); + expect(result).toContain('→'); + expect(result).toContain('✅'); + }); + }); + + describe('renderOpinionLine', () => { + it('should render opinion with agent name and stance', () => { + const opinion = createAgentOpinion({ + agentId: 'arch-1', + agentName: 'architecture', + stance: 'approve', + reasoning: 'Clean design', + }); + + const line = renderOpinionLine(opinion); + + expect(line.type).toBe('opinion'); + expect(line.text).toContain('architecture'); + expect(line.text).toContain('✅'); + expect(line.text).toContain('Clean design'); + }); + + it('should show (revised) suffix when stance changed from concern/reject', () => { + const opinion = createAgentOpinion({ + agentId: 'arch-1', + agentName: 'architecture', + stance: 'approve', + reasoning: 'Now ok', + }); + + const line = renderOpinionLine(opinion, ['concern', 'approve']); + + expect(line.text).toContain('(revised)'); + }); + + it('should not show suffix when no stance change', () => { + const opinion = createAgentOpinion({ + agentId: 'arch-1', + agentName: 'architecture', + stance: 'approve', + reasoning: 'Still ok', + }); + + const line = renderOpinionLine(opinion, ['approve']); + + expect(line.text).not.toContain('(revised)'); + expect(line.text).not.toContain('(alt)'); + }); + }); + + describe('renderCrossReviewLine', () => { + it('should render arrow notation between agents', () => { + const review = createCrossReview({ + fromAgentId: 'arch-1', + toAgentId: 'sec-1', + stance: 'approve', + comment: 'Good approach', + }); + + const line = renderCrossReviewLine(review, { + 'arch-1': 'architecture', + 'sec-1': 'security', + }); + + expect(line.type).toBe('cross-review'); + expect(line.text).toContain('architecture'); + expect(line.text).toContain('→'); + expect(line.text).toContain('security'); + expect(line.text).toContain('agrees'); + }); + }); + + describe('renderConsensusLine', () => { + it('should render consensus reached', () => { + const consensus: ConsensusResult = { + totalAgents: 3, + approveCount: 3, + concernCount: 0, + rejectCount: 0, + reached: true, + criticalCount: 0, + }; + + const line = renderConsensusLine(consensus); + + expect(line.type).toBe('consensus'); + expect(line.text).toContain('✅'); + expect(line.text).toContain('3/3'); + expect(line.text).toContain('Critical: 0'); + }); + + it('should render consensus not reached', () => { + const consensus: ConsensusResult = { + totalAgents: 3, + approveCount: 1, + concernCount: 0, + rejectCount: 2, + reached: false, + criticalCount: 2, + }; + + const line = renderConsensusLine(consensus); + + expect(line.text).toContain('❌'); + expect(line.text).toContain('Critical: 2'); + }); + }); + + describe('renderDiscussionPanel', () => { + it('should render empty message when no rounds have opinions', () => { + const lines = renderDiscussionPanel([], 80); + + expect(lines).toHaveLength(1); + expect(lines[0].type).toBe('empty'); + }); + + it('should render header, opinions, and consensus for a round', () => { + const round: DiscussionRound = { + roundNumber: 1, + opinions: [ + createAgentOpinion({ + agentId: 'arch-1', + agentName: 'architecture', + stance: 'approve', + reasoning: 'Good', + }), + createAgentOpinion({ + agentId: 'sec-1', + agentName: 'security', + stance: 'concern', + reasoning: 'Watch auth', + }), + ], + crossReviews: [], + }; + + const lines = renderDiscussionPanel([round], 80); + + const types = lines.map(l => l.type); + expect(types).toContain('header'); + expect(types).toContain('opinion'); + expect(types).toContain('consensus'); + expect(lines.filter(l => l.type === 'opinion')).toHaveLength(2); + }); + + it('should include cross-reviews when present', () => { + const round: DiscussionRound = { + roundNumber: 1, + opinions: [ + createAgentOpinion({ + agentId: 'arch-1', + agentName: 'architecture', + stance: 'concern', + reasoning: 'Risk', + }), + createAgentOpinion({ + agentId: 'sec-1', + agentName: 'security', + stance: 'reject', + reasoning: 'No', + }), + ], + crossReviews: [ + createCrossReview({ + fromAgentId: 'arch-1', + toAgentId: 'sec-1', + stance: 'approve', + comment: 'Solution found', + }), + ], + }; + + const lines = renderDiscussionPanel([round], 80); + + const types = lines.map(l => l.type); + expect(types).toContain('cross-review'); + }); + }); +}); diff --git a/apps/mcp-server/src/tui/components/agent-discussion-panel.pure.ts b/apps/mcp-server/src/tui/components/agent-discussion-panel.pure.ts new file mode 100644 index 0000000..1800be9 --- /dev/null +++ b/apps/mcp-server/src/tui/components/agent-discussion-panel.pure.ts @@ -0,0 +1,143 @@ +/** + * Agent Discussion Panel — Pure rendering logic. + * + * Stateless functions that produce display lines from discussion data. + * Follows the same pattern as flow-map.pure.ts. + */ +import type { + AgentOpinion, + CrossReview, + ConsensusResult, + DiscussionRound, + Stance, +} from '../../collaboration/types'; +import { STANCE_ICONS, calculateConsensus } from '../../collaboration/types'; +import { getAgentAvatar } from '../utils/theme'; + +/** A single styled line for the discussion panel display. */ +export interface DiscussionLine { + readonly text: string; + readonly type: 'opinion' | 'cross-review' | 'consensus' | 'header' | 'empty'; +} + +/** Map stance to cross-review verb. */ +const CROSS_REVIEW_VERBS: Readonly> = { + approve: 'agrees', + concern: 'notes', + reject: 'disagrees', +}; + +/** Render stance history as arrow-separated icons (e.g., "⚠️ → ✅"). */ +export function renderStanceHistory(stances: Stance[]): string { + if (stances.length <= 1) return ''; + return stances.map(s => STANCE_ICONS[s]).join(' → '); +} + +/** Render a single opinion line with stance history. */ +export function renderOpinionLine(opinion: AgentOpinion, stanceHistory?: Stance[]): DiscussionLine { + const avatar = getAgentAvatar(opinion.agentName); + const icon = STANCE_ICONS[opinion.stance]; + + let suffix = ''; + if (stanceHistory && stanceHistory.length > 1) { + const lastStance = stanceHistory[stanceHistory.length - 1]; + const prevStance = stanceHistory[stanceHistory.length - 2]; + if (lastStance !== prevStance) { + suffix = ` (${prevStance === 'reject' || prevStance === 'concern' ? 'revised' : 'alt'})`; + } + } + + return { + text: `${avatar} ${opinion.agentName} ${icon} ${opinion.stance}${suffix}: "${opinion.reasoning}"`, + type: 'opinion', + }; +} + +/** Render a cross-review line. */ +export function renderCrossReviewLine( + review: CrossReview, + agentNames: Record, +): DiscussionLine { + const fromAvatar = getAgentAvatar(agentNames[review.fromAgentId] ?? 'unknown'); + const fromName = agentNames[review.fromAgentId] ?? review.fromAgentId; + const toAvatar = getAgentAvatar(agentNames[review.toAgentId] ?? 'unknown'); + const toName = agentNames[review.toAgentId] ?? review.toAgentId; + const verb = CROSS_REVIEW_VERBS[review.stance]; + + return { + text: `${fromAvatar} ${fromName} → ${toAvatar} ${toName} ${verb}: "${review.comment}"`, + type: 'cross-review', + }; +} + +/** Render the consensus summary line. */ +export function renderConsensusLine(consensus: ConsensusResult): DiscussionLine { + const icon = consensus.reached ? '✅' : '❌'; + return { + text: `${icon} Consensus: ${consensus.approveCount}/${consensus.totalAgents} | Critical: ${consensus.criticalCount}`, + type: 'consensus', + }; +} + +/** Build agent name lookup from opinions. */ +function buildAgentNameMap(rounds: readonly DiscussionRound[]): Record { + const map: Record = {}; + for (const round of rounds) { + for (const opinion of round.opinions) { + map[opinion.agentId] = opinion.agentName; + } + } + return map; +} + +/** Build stance history for each agent across rounds. */ +function buildStanceHistories(rounds: readonly DiscussionRound[]): Record { + const histories: Record = {}; + for (const round of rounds) { + for (const opinion of round.opinions) { + if (!histories[opinion.agentId]) { + histories[opinion.agentId] = []; + } + histories[opinion.agentId].push(opinion.stance); + } + } + return histories; +} + +/** + * Render all discussion rounds into display lines. + * This is the main pure function for the panel. + */ +export function renderDiscussionPanel( + rounds: readonly DiscussionRound[], + _width: number, +): DiscussionLine[] { + if (rounds.length === 0) { + return [{ text: 'No agent discussion yet', type: 'empty' }]; + } + + const lines: DiscussionLine[] = []; + const agentNames = buildAgentNameMap(rounds); + const stanceHistories = buildStanceHistories(rounds); + + // Show the latest round's data + const latestRound = rounds[rounds.length - 1]; + + lines.push({ text: `── Agent Discussion ──`, type: 'header' }); + + // Opinions + for (const opinion of latestRound.opinions) { + lines.push(renderOpinionLine(opinion, stanceHistories[opinion.agentId])); + } + + // Cross-reviews + for (const review of latestRound.crossReviews) { + lines.push(renderCrossReviewLine(review, agentNames)); + } + + // Consensus + const consensus = calculateConsensus(latestRound.opinions); + lines.push(renderConsensusLine(consensus)); + + return lines; +} diff --git a/apps/mcp-server/src/tui/components/index.ts b/apps/mcp-server/src/tui/components/index.ts index abfe32c..3b804a0 100644 --- a/apps/mcp-server/src/tui/components/index.ts +++ b/apps/mcp-server/src/tui/components/index.ts @@ -7,6 +7,14 @@ export { layoutStageColumns, layoutAgentNodes, } from './flow-map.pure'; +export { AgentDiscussionPanel, type AgentDiscussionPanelProps } from './AgentDiscussionPanel'; +export { + renderDiscussionPanel, + renderOpinionLine, + renderCrossReviewLine, + renderConsensusLine, + renderStanceHistory, +} from './agent-discussion-panel.pure'; export { FocusedAgentPanel, type FocusedAgentPanelProps } from './FocusedAgentPanel'; export { ChecklistPanel, type ChecklistPanelProps } from './ChecklistPanel'; export { resolveChecklistTasks } from './checklist-panel.pure'; diff --git a/apps/mcp-server/src/tui/dashboard-app.tsx b/apps/mcp-server/src/tui/dashboard-app.tsx index 352726f..20d1f3e 100644 --- a/apps/mcp-server/src/tui/dashboard-app.tsx +++ b/apps/mcp-server/src/tui/dashboard-app.tsx @@ -7,6 +7,7 @@ import { useTick } from './hooks/use-tick'; import { useDashboardState } from './hooks/use-dashboard-state'; import { HeaderBar } from './components/HeaderBar'; import { FlowMap } from './components/FlowMap'; +import { AgentDiscussionPanel } from './components/AgentDiscussionPanel'; import { FocusedAgentPanel } from './components/FocusedAgentPanel'; import { ChecklistPanel } from './components/ChecklistPanel'; import { StageHealthBar } from './components/StageHealthBar'; @@ -77,24 +78,13 @@ export function DashboardApp({ tick={tick} now={now} /> - - - ) : ( - - + {state.discussionRounds.length > 0 ? ( + + ) : ( + )} + + ) : ( + + + {state.discussionRounds.length > 0 ? ( + + ) : ( + + )} { contextNotes: [], contextMode: null, contextStatus: null, + discussionRounds: [], }; expect(state.toolInvokeCount).toBe(0); expect(state.outputStats).toEqual({ files: 0, commits: 0 }); @@ -224,6 +225,7 @@ describe('tui/dashboard-types', () => { contextNotes: [], contextMode: null, contextStatus: null, + discussionRounds: [], }; expect(state.agentActivateCount).toBe(0); expect(state.skillInvokeCount).toBe(0); diff --git a/apps/mcp-server/src/tui/dashboard-types.ts b/apps/mcp-server/src/tui/dashboard-types.ts index db85129..50bf5ad 100644 --- a/apps/mcp-server/src/tui/dashboard-types.ts +++ b/apps/mcp-server/src/tui/dashboard-types.ts @@ -5,6 +5,7 @@ * task progress, stage health, and event logs. */ import type { Mode } from './types'; +import type { DiscussionRound } from '../collaboration/types'; /** * Layout breakpoints based on terminal column width. @@ -178,6 +179,7 @@ export interface DashboardState { contextNotes: string[]; contextMode: string | null; contextStatus: string | null; + discussionRounds: DiscussionRound[]; } /** diff --git a/apps/mcp-server/src/tui/hooks/use-dashboard-state.ts b/apps/mcp-server/src/tui/hooks/use-dashboard-state.ts index 352a7e0..3e38c6d 100644 --- a/apps/mcp-server/src/tui/hooks/use-dashboard-state.ts +++ b/apps/mcp-server/src/tui/hooks/use-dashboard-state.ts @@ -60,6 +60,7 @@ export function createInitialDashboardState(): DashboardState { contextNotes: [], contextMode: null, contextStatus: null, + discussionRounds: [], }; }