diff --git a/apps/mcp-server/src/agent/agent.service.spec.ts b/apps/mcp-server/src/agent/agent.service.spec.ts index 7e6cfbba..f54b87ce 100644 --- a/apps/mcp-server/src/agent/agent.service.spec.ts +++ b/apps/mcp-server/src/agent/agent.service.spec.ts @@ -3,6 +3,7 @@ import { AgentService } from './agent.service'; import type { RulesService } from '../rules/rules.service'; import type { CustomService } from '../custom'; import type { ConfigService } from '../config/config.service'; +import type { TeamsCapabilityService } from './teams-capability.service'; import type { AgentProfile } from '../rules/rules.types'; import type { AgentContext, DispatchResult } from './agent.types'; @@ -11,6 +12,7 @@ describe('AgentService', () => { let mockRulesService: Partial; let mockCustomService: Partial; let mockConfigService: Partial; + let mockTeamsCapability: Partial; const mockSecurityAgent: AgentProfile = { name: 'Security Specialist', @@ -52,11 +54,20 @@ describe('AgentService', () => { mockConfigService = { getProjectRoot: vi.fn().mockReturnValue('/test/project'), }; + mockTeamsCapability = { + isAvailable: vi.fn().mockResolvedValue(true), + getStatus: vi.fn().mockResolvedValue({ + available: true, + reason: 'Enabled for testing', + source: 'environment', + }), + }; service = new AgentService( mockRulesService as RulesService, mockCustomService as CustomService, mockConfigService as ConfigService, + mockTeamsCapability as TeamsCapabilityService, ); }); @@ -918,6 +929,85 @@ describe('AgentService', () => { }); }); + describe('dispatchAgents taskmaestro+teams inner coordination bootstrap', () => { + it('should populate innerCoordination on assignments when Teams capability is enabled', async () => { + vi.mocked(mockTeamsCapability.isAvailable!).mockResolvedValue(true); + vi.mocked(mockRulesService.getAgent!) + .mockResolvedValueOnce(mockSecurityAgent) + .mockResolvedValueOnce(mockPerformanceAgent); + + const result = await service.dispatchAgents({ + mode: 'EVAL', + specialists: ['security-specialist', 'performance-specialist'], + executionStrategy: 'taskmaestro+teams', + }); + + expect(result.taskmaestro).toBeDefined(); + const assignments = result.taskmaestro!.assignments; + expect(assignments).toHaveLength(2); + + // Each assignment carries inner coordination metadata + for (const assignment of assignments) { + expect(assignment.innerCoordination).toBeDefined(); + expect(assignment.innerCoordination!.type).toBe('teams'); + expect(assignment.innerCoordination!.teamSpec.team_name).toBe('eval-specialists'); + expect(assignment.innerCoordination!.teammates).toHaveLength(2); + expect(assignment.innerCoordination!.teammates[0].subagent_type).toBe('general-purpose'); + } + }); + + it('should omit innerCoordination and fall back to pure taskmaestro when Teams capability is disabled', async () => { + vi.mocked(mockTeamsCapability.isAvailable!).mockResolvedValue(false); + vi.mocked(mockRulesService.getAgent!) + .mockResolvedValueOnce(mockSecurityAgent) + .mockResolvedValueOnce(mockPerformanceAgent); + + const result = await service.dispatchAgents({ + mode: 'EVAL', + specialists: ['security-specialist', 'performance-specialist'], + executionStrategy: 'taskmaestro+teams', + }); + + // Falls back to pure taskmaestro + expect(result.executionStrategy).toBe('taskmaestro'); + expect(result.taskmaestro).toBeDefined(); + expect(result.teams).toBeUndefined(); + + // No inner coordination on assignments + for (const assignment of result.taskmaestro!.assignments) { + expect(assignment.innerCoordination).toBeUndefined(); + } + + // executionPlan should be simple (no inner) + expect(result.executionPlan).toBeDefined(); + expect(result.executionPlan!.outerExecution.type).toBe('taskmaestro'); + expect(result.executionPlan!.innerCoordination).toBeUndefined(); + }); + + it('should include teammate list matching loaded agents in innerCoordination', async () => { + vi.mocked(mockTeamsCapability.isAvailable!).mockResolvedValue(true); + vi.mocked(mockRulesService.getAgent!) + .mockResolvedValueOnce(mockSecurityAgent) + .mockRejectedValueOnce(new Error('Agent not found')) + .mockResolvedValueOnce(mockPerformanceAgent); + + const result = await service.dispatchAgents({ + mode: 'EVAL', + specialists: ['security-specialist', 'invalid-agent', 'performance-specialist'], + executionStrategy: 'taskmaestro+teams', + }); + + // Only successfully loaded agents appear + expect(result.taskmaestro!.assignments).toHaveLength(2); + expect(result.failedAgents).toHaveLength(1); + + // innerCoordination teammates match the successfully loaded agents + const teammates = result.taskmaestro!.assignments[0].innerCoordination!.teammates; + expect(teammates).toHaveLength(2); + expect(teammates.map(t => t.name)).toEqual(['security-specialist', 'performance-specialist']); + }); + }); + describe('executionPlan in all strategies', () => { it('subagent strategy populates executionPlan', async () => { vi.mocked(mockRulesService.getAgent!).mockResolvedValueOnce(mockSecurityAgent); diff --git a/apps/mcp-server/src/agent/agent.service.ts b/apps/mcp-server/src/agent/agent.service.ts index 2ec7e6a9..344baf24 100644 --- a/apps/mcp-server/src/agent/agent.service.ts +++ b/apps/mcp-server/src/agent/agent.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { RulesService } from '../rules/rules.service'; import { CustomService } from '../custom'; import { ConfigService } from '../config/config.service'; +import { TeamsCapabilityService } from './teams-capability.service'; import type { Mode } from '../keyword/keyword.types'; import type { AgentProfile } from '../rules/rules.types'; import type { @@ -43,6 +44,7 @@ export class AgentService { private readonly rulesService: RulesService, private readonly customService: CustomService, private readonly configService: ConfigService, + private readonly teamsCapability: TeamsCapabilityService, ) {} /** @@ -392,12 +394,25 @@ export class AgentService { /** * Dispatch with composable taskmaestro+teams strategy. * TaskMaestro manages tmux panes (outer), Teams coordinates within panes (inner). + * + * When Teams capability is disabled, falls back to pure TaskMaestro + * (omits inner coordination fields from assignments). */ private async dispatchComposable( input: DispatchAgentsInput, context: AgentContext, result: DispatchResult, ): Promise { + const teamsAvailable = await this.teamsCapability.isAvailable(); + + // If Teams is not available, fall back to pure TaskMaestro + if (!teamsAvailable) { + this.logger.debug( + 'Teams capability disabled — falling back to pure TaskMaestro for composable dispatch', + ); + return this.dispatchTaskmaestro(input, context, result); + } + const uniqueSpecialists = Array.from(new Set(input.specialists!)); const teamName = `${(input.mode ?? 'eval').toLowerCase()}-specialists`; const { agents, failedAgents } = await this.loadAgents( @@ -407,11 +422,22 @@ export class AgentService { input.inlineAgents, ); - // Build TaskMaestro assignments (outer transport) + // Build TaskMaestro assignments with inner coordination metadata const assignments: TaskmaestroAssignment[] = agents.map(agent => ({ name: agent.id, displayName: agent.displayName, prompt: this.buildTaskmaestroPrompt(agent, input), + innerCoordination: { + type: 'teams' as const, + teamSpec: { + team_name: teamName, + description: `${input.mode} mode specialist team (coordinated within TaskMaestro panes)`, + }, + teammates: agents.map(a => ({ + name: a.id, + subagent_type: 'general-purpose' as const, + })), + }, })); const tmDispatch = { diff --git a/apps/mcp-server/src/agent/agent.types.ts b/apps/mcp-server/src/agent/agent.types.ts index 3913fedb..03f3965d 100644 --- a/apps/mcp-server/src/agent/agent.types.ts +++ b/apps/mcp-server/src/agent/agent.types.ts @@ -90,12 +90,34 @@ export interface DispatchedAgent { } /** - * A single TaskMaestro pane assignment with agent name and prompt + * Inner Teams coordination metadata embedded in a TaskMaestro pane assignment. + * Present only when the composable `taskmaestro+teams` strategy is active + * and the Teams capability gate is enabled. + */ +export interface InnerTeamsSpec { + type: 'teams'; + teamSpec: { + team_name: string; + description: string; + }; + teammates: Array<{ + name: string; + subagent_type: 'general-purpose'; + }>; +} + +/** + * A single TaskMaestro pane assignment with agent name and prompt. + * When the composable `taskmaestro+teams` strategy is active, + * `innerCoordination` carries the metadata a pane worker needs + * to bootstrap its inner Teams workflow. */ export interface TaskmaestroAssignment { name: string; displayName: string; prompt: string; + /** Inner coordination metadata for composable taskmaestro+teams execution */ + innerCoordination?: InnerTeamsSpec; } /**