Skip to content

Commit 4177533

Browse files
committed
feat(mcp): extend TaskMaestro pane assignments for inner Teams bootstrap (#1313)
Add InnerTeamsSpec type and optional innerCoordination field to TaskmaestroAssignment so pane workers can bootstrap inner Teams workflows in the composable taskmaestro+teams strategy. - Inject TeamsCapabilityService into AgentService - Gate composable dispatch on Teams capability (falls back to pure TaskMaestro when disabled) - Populate innerCoordination with teamSpec and teammates on each pane assignment when Teams is available - Add tests: nested assignment with inner teams, pure taskmaestro fallback, failed agent handling in composable mode
1 parent 3088596 commit 4177533

3 files changed

Lines changed: 140 additions & 2 deletions

File tree

apps/mcp-server/src/agent/agent.service.spec.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { AgentService } from './agent.service';
33
import type { RulesService } from '../rules/rules.service';
44
import type { CustomService } from '../custom';
55
import type { ConfigService } from '../config/config.service';
6+
import type { TeamsCapabilityService } from './teams-capability.service';
67
import type { AgentProfile } from '../rules/rules.types';
78
import type { AgentContext, DispatchResult } from './agent.types';
89

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

1517
const mockSecurityAgent: AgentProfile = {
1618
name: 'Security Specialist',
@@ -52,11 +54,20 @@ describe('AgentService', () => {
5254
mockConfigService = {
5355
getProjectRoot: vi.fn().mockReturnValue('/test/project'),
5456
};
57+
mockTeamsCapability = {
58+
isAvailable: vi.fn().mockResolvedValue(true),
59+
getStatus: vi.fn().mockResolvedValue({
60+
available: true,
61+
reason: 'Enabled for testing',
62+
source: 'environment',
63+
}),
64+
};
5565

5666
service = new AgentService(
5767
mockRulesService as RulesService,
5868
mockCustomService as CustomService,
5969
mockConfigService as ConfigService,
70+
mockTeamsCapability as TeamsCapabilityService,
6071
);
6172
});
6273

@@ -918,6 +929,85 @@ describe('AgentService', () => {
918929
});
919930
});
920931

932+
describe('dispatchAgents taskmaestro+teams inner coordination bootstrap', () => {
933+
it('should populate innerCoordination on assignments when Teams capability is enabled', async () => {
934+
vi.mocked(mockTeamsCapability.isAvailable!).mockResolvedValue(true);
935+
vi.mocked(mockRulesService.getAgent!)
936+
.mockResolvedValueOnce(mockSecurityAgent)
937+
.mockResolvedValueOnce(mockPerformanceAgent);
938+
939+
const result = await service.dispatchAgents({
940+
mode: 'EVAL',
941+
specialists: ['security-specialist', 'performance-specialist'],
942+
executionStrategy: 'taskmaestro+teams',
943+
});
944+
945+
expect(result.taskmaestro).toBeDefined();
946+
const assignments = result.taskmaestro!.assignments;
947+
expect(assignments).toHaveLength(2);
948+
949+
// Each assignment carries inner coordination metadata
950+
for (const assignment of assignments) {
951+
expect(assignment.innerCoordination).toBeDefined();
952+
expect(assignment.innerCoordination!.type).toBe('teams');
953+
expect(assignment.innerCoordination!.teamSpec.team_name).toBe('eval-specialists');
954+
expect(assignment.innerCoordination!.teammates).toHaveLength(2);
955+
expect(assignment.innerCoordination!.teammates[0].subagent_type).toBe('general-purpose');
956+
}
957+
});
958+
959+
it('should omit innerCoordination and fall back to pure taskmaestro when Teams capability is disabled', async () => {
960+
vi.mocked(mockTeamsCapability.isAvailable!).mockResolvedValue(false);
961+
vi.mocked(mockRulesService.getAgent!)
962+
.mockResolvedValueOnce(mockSecurityAgent)
963+
.mockResolvedValueOnce(mockPerformanceAgent);
964+
965+
const result = await service.dispatchAgents({
966+
mode: 'EVAL',
967+
specialists: ['security-specialist', 'performance-specialist'],
968+
executionStrategy: 'taskmaestro+teams',
969+
});
970+
971+
// Falls back to pure taskmaestro
972+
expect(result.executionStrategy).toBe('taskmaestro');
973+
expect(result.taskmaestro).toBeDefined();
974+
expect(result.teams).toBeUndefined();
975+
976+
// No inner coordination on assignments
977+
for (const assignment of result.taskmaestro!.assignments) {
978+
expect(assignment.innerCoordination).toBeUndefined();
979+
}
980+
981+
// executionPlan should be simple (no inner)
982+
expect(result.executionPlan).toBeDefined();
983+
expect(result.executionPlan!.outerExecution.type).toBe('taskmaestro');
984+
expect(result.executionPlan!.innerCoordination).toBeUndefined();
985+
});
986+
987+
it('should include teammate list matching loaded agents in innerCoordination', async () => {
988+
vi.mocked(mockTeamsCapability.isAvailable!).mockResolvedValue(true);
989+
vi.mocked(mockRulesService.getAgent!)
990+
.mockResolvedValueOnce(mockSecurityAgent)
991+
.mockRejectedValueOnce(new Error('Agent not found'))
992+
.mockResolvedValueOnce(mockPerformanceAgent);
993+
994+
const result = await service.dispatchAgents({
995+
mode: 'EVAL',
996+
specialists: ['security-specialist', 'invalid-agent', 'performance-specialist'],
997+
executionStrategy: 'taskmaestro+teams',
998+
});
999+
1000+
// Only successfully loaded agents appear
1001+
expect(result.taskmaestro!.assignments).toHaveLength(2);
1002+
expect(result.failedAgents).toHaveLength(1);
1003+
1004+
// innerCoordination teammates match the successfully loaded agents
1005+
const teammates = result.taskmaestro!.assignments[0].innerCoordination!.teammates;
1006+
expect(teammates).toHaveLength(2);
1007+
expect(teammates.map(t => t.name)).toEqual(['security-specialist', 'performance-specialist']);
1008+
});
1009+
});
1010+
9211011
describe('executionPlan in all strategies', () => {
9221012
it('subagent strategy populates executionPlan', async () => {
9231013
vi.mocked(mockRulesService.getAgent!).mockResolvedValueOnce(mockSecurityAgent);

apps/mcp-server/src/agent/agent.service.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
22
import { RulesService } from '../rules/rules.service';
33
import { CustomService } from '../custom';
44
import { ConfigService } from '../config/config.service';
5+
import { TeamsCapabilityService } from './teams-capability.service';
56
import type { Mode } from '../keyword/keyword.types';
67
import type { AgentProfile } from '../rules/rules.types';
78
import type {
@@ -43,6 +44,7 @@ export class AgentService {
4344
private readonly rulesService: RulesService,
4445
private readonly customService: CustomService,
4546
private readonly configService: ConfigService,
47+
private readonly teamsCapability: TeamsCapabilityService,
4648
) {}
4749

4850
/**
@@ -392,12 +394,25 @@ export class AgentService {
392394
/**
393395
* Dispatch with composable taskmaestro+teams strategy.
394396
* TaskMaestro manages tmux panes (outer), Teams coordinates within panes (inner).
397+
*
398+
* When Teams capability is disabled, falls back to pure TaskMaestro
399+
* (omits inner coordination fields from assignments).
395400
*/
396401
private async dispatchComposable(
397402
input: DispatchAgentsInput,
398403
context: AgentContext,
399404
result: DispatchResult,
400405
): Promise<DispatchResult> {
406+
const teamsAvailable = await this.teamsCapability.isAvailable();
407+
408+
// If Teams is not available, fall back to pure TaskMaestro
409+
if (!teamsAvailable) {
410+
this.logger.debug(
411+
'Teams capability disabled — falling back to pure TaskMaestro for composable dispatch',
412+
);
413+
return this.dispatchTaskmaestro(input, context, result);
414+
}
415+
401416
const uniqueSpecialists = Array.from(new Set(input.specialists!));
402417
const teamName = `${(input.mode ?? 'eval').toLowerCase()}-specialists`;
403418
const { agents, failedAgents } = await this.loadAgents(
@@ -407,11 +422,22 @@ export class AgentService {
407422
input.inlineAgents,
408423
);
409424

410-
// Build TaskMaestro assignments (outer transport)
425+
// Build TaskMaestro assignments with inner coordination metadata
411426
const assignments: TaskmaestroAssignment[] = agents.map(agent => ({
412427
name: agent.id,
413428
displayName: agent.displayName,
414429
prompt: this.buildTaskmaestroPrompt(agent, input),
430+
innerCoordination: {
431+
type: 'teams' as const,
432+
teamSpec: {
433+
team_name: teamName,
434+
description: `${input.mode} mode specialist team (coordinated within TaskMaestro panes)`,
435+
},
436+
teammates: agents.map(a => ({
437+
name: a.id,
438+
subagent_type: 'general-purpose' as const,
439+
})),
440+
},
415441
}));
416442

417443
const tmDispatch = {

apps/mcp-server/src/agent/agent.types.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,34 @@ export interface DispatchedAgent {
9090
}
9191

9292
/**
93-
* A single TaskMaestro pane assignment with agent name and prompt
93+
* Inner Teams coordination metadata embedded in a TaskMaestro pane assignment.
94+
* Present only when the composable `taskmaestro+teams` strategy is active
95+
* and the Teams capability gate is enabled.
96+
*/
97+
export interface InnerTeamsSpec {
98+
type: 'teams';
99+
teamSpec: {
100+
team_name: string;
101+
description: string;
102+
};
103+
teammates: Array<{
104+
name: string;
105+
subagent_type: 'general-purpose';
106+
}>;
107+
}
108+
109+
/**
110+
* A single TaskMaestro pane assignment with agent name and prompt.
111+
* When the composable `taskmaestro+teams` strategy is active,
112+
* `innerCoordination` carries the metadata a pane worker needs
113+
* to bootstrap its inner Teams workflow.
94114
*/
95115
export interface TaskmaestroAssignment {
96116
name: string;
97117
displayName: string;
98118
prompt: string;
119+
/** Inner coordination metadata for composable taskmaestro+teams execution */
120+
innerCoordination?: InnerTeamsSpec;
99121
}
100122

101123
/**

0 commit comments

Comments
 (0)