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
90 changes: 90 additions & 0 deletions apps/mcp-server/src/agent/agent.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -11,6 +12,7 @@ describe('AgentService', () => {
let mockRulesService: Partial<RulesService>;
let mockCustomService: Partial<CustomService>;
let mockConfigService: Partial<ConfigService>;
let mockTeamsCapability: Partial<TeamsCapabilityService>;

const mockSecurityAgent: AgentProfile = {
name: 'Security Specialist',
Expand Down Expand Up @@ -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,
);
});

Expand Down Expand Up @@ -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);
Expand Down
28 changes: 27 additions & 1 deletion apps/mcp-server/src/agent/agent.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -43,6 +44,7 @@ export class AgentService {
private readonly rulesService: RulesService,
private readonly customService: CustomService,
private readonly configService: ConfigService,
private readonly teamsCapability: TeamsCapabilityService,
) {}

/**
Expand Down Expand Up @@ -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<DispatchResult> {
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(
Expand All @@ -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 = {
Expand Down
24 changes: 23 additions & 1 deletion apps/mcp-server/src/agent/agent.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
Loading