diff --git a/apps/mcp-server/src/agent/agent.service.spec.ts b/apps/mcp-server/src/agent/agent.service.spec.ts index d727a606..7e6cfbba 100644 --- a/apps/mcp-server/src/agent/agent.service.spec.ts +++ b/apps/mcp-server/src/agent/agent.service.spec.ts @@ -804,4 +804,183 @@ describe('AgentService', () => { expect(result.executionHint).toContain('/taskmaestro stop all'); }); }); + + describe('dispatchAgents with taskmaestro+teams composable strategy', () => { + it('should return both taskmaestro and teams fields', async () => { + 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(); + expect(result.teams).toBeDefined(); + expect(result.executionStrategy).toBe('taskmaestro+teams'); + }); + + it('should set executionPlan with nested layers', async () => { + 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.executionPlan).toBeDefined(); + expect(result.executionPlan!.outerExecution.type).toBe('taskmaestro'); + expect(result.executionPlan!.innerCoordination).toBeDefined(); + expect(result.executionPlan!.innerCoordination!.type).toBe('teams'); + }); + + it('should match pane count to specialists', async () => { + 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!.paneCount).toBe(2); + expect(result.teams!.teammates).toHaveLength(2); + }); + + it('should use mode-based session/team name', async () => { + vi.mocked(mockRulesService.getAgent!).mockResolvedValueOnce(mockSecurityAgent); + + const result = await service.dispatchAgents({ + mode: 'PLAN', + specialists: ['security-specialist'], + executionStrategy: 'taskmaestro+teams', + }); + + expect(result.taskmaestro!.sessionName).toBe('plan-specialists'); + expect(result.teams!.team_name).toBe('plan-specialists'); + }); + + it('should include composable execution hint', async () => { + 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.executionHint).toContain('Composable execution'); + expect(result.executionHint).toContain('TaskMaestro'); + expect(result.executionHint).toContain('Teams'); + }); + + it('should include primary agent when provided', async () => { + vi.mocked(mockRulesService.getAgent!) + .mockResolvedValueOnce(mockSecurityAgent) // primary + .mockResolvedValueOnce(mockPerformanceAgent); // specialist + + const result = await service.dispatchAgents({ + mode: 'EVAL', + primaryAgent: 'security-specialist', + specialists: ['performance-specialist'], + executionStrategy: 'taskmaestro+teams', + }); + + expect(result.primaryAgent).toBeDefined(); + expect(result.primaryAgent!.name).toBe('security-specialist'); + expect(result.taskmaestro).toBeDefined(); + expect(result.teams).toBeDefined(); + }); + + it('should handle failed agents in composable strategy', async () => { + vi.mocked(mockRulesService.getAgent!) + .mockResolvedValueOnce(mockSecurityAgent) + .mockRejectedValueOnce(new Error('Agent not found')); + + const result = await service.dispatchAgents({ + mode: 'EVAL', + specialists: ['security-specialist', 'invalid-agent'], + executionStrategy: 'taskmaestro+teams', + }); + + expect(result.taskmaestro!.paneCount).toBe(1); + expect(result.teams!.teammates).toHaveLength(1); + expect(result.failedAgents).toHaveLength(1); + }); + }); + + describe('executionPlan in all strategies', () => { + it('subagent strategy populates executionPlan', async () => { + vi.mocked(mockRulesService.getAgent!).mockResolvedValueOnce(mockSecurityAgent); + + const result = await service.dispatchAgents({ + mode: 'EVAL', + specialists: ['security-specialist'], + includeParallel: true, + }); + + expect(result.executionPlan).toBeDefined(); + expect(result.executionPlan!.outerExecution.type).toBe('subagent'); + expect(result.executionPlan!.innerCoordination).toBeUndefined(); + }); + + it('taskmaestro strategy populates executionPlan', async () => { + vi.mocked(mockRulesService.getAgent!).mockResolvedValueOnce(mockSecurityAgent); + + const result = await service.dispatchAgents({ + mode: 'EVAL', + specialists: ['security-specialist'], + executionStrategy: 'taskmaestro', + }); + + expect(result.executionPlan).toBeDefined(); + expect(result.executionPlan!.outerExecution.type).toBe('taskmaestro'); + expect(result.executionPlan!.innerCoordination).toBeUndefined(); + }); + + it('teams strategy populates executionPlan', async () => { + vi.mocked(mockRulesService.getAgent!).mockResolvedValueOnce(mockSecurityAgent); + + const result = await service.dispatchAgents({ + mode: 'EVAL', + specialists: ['security-specialist'], + executionStrategy: 'teams', + }); + + expect(result.executionPlan).toBeDefined(); + expect(result.executionPlan!.outerExecution.type).toBe('teams'); + expect(result.executionPlan!.innerCoordination).toBeUndefined(); + }); + + it('no specialists results in no executionPlan', async () => { + const result = await service.dispatchAgents({ + mode: 'EVAL', + }); + + expect(result.executionPlan).toBeUndefined(); + }); + + it('backward compat: no executionStrategy still defaults to subagent', async () => { + vi.mocked(mockRulesService.getAgent!).mockResolvedValueOnce(mockSecurityAgent); + + const result = await service.dispatchAgents({ + mode: 'EVAL', + specialists: ['security-specialist'], + includeParallel: true, + }); + + expect(result.executionStrategy).toBe('subagent'); + expect(result.executionPlan).toBeDefined(); + expect(result.executionPlan!.outerExecution.type).toBe('subagent'); + }); + }); }); diff --git a/apps/mcp-server/src/agent/agent.service.ts b/apps/mcp-server/src/agent/agent.service.ts index 95243e30..2ec7e6a9 100644 --- a/apps/mcp-server/src/agent/agent.service.ts +++ b/apps/mcp-server/src/agent/agent.service.ts @@ -16,6 +16,7 @@ import type { DispatchedAgent, TaskmaestroAssignment, TeamsTeammate, + ExecutionPlan, } from './agent.types'; import { FILE_PATTERN_SPECIALISTS } from './agent.types'; import { @@ -24,6 +25,13 @@ import { buildParallelExecutionHint, } from './agent-prompt.builder'; import { createAgentSummary } from './agent-summary.utils'; +import { + buildSimplePlan, + buildNestedPlan, + subagentLayer, + taskmaestroLayer, + teamsLayer, +} from './execution-plan'; import type { VerbosityLevel } from '../shared/verbosity.types'; import { getVerbosityConfig } from '../shared/verbosity.types'; @@ -207,6 +215,12 @@ export class AgentService { * * Returns structured dispatch data that can be directly used with * Claude Code's Task tool, eliminating the need for manual prompt assembly. + * + * Supports composable execution strategies: + * - `'subagent'` (default): parallel subagents via Claude Code Agent tool + * - `'taskmaestro'`: tmux-based pane execution + * - `'teams'`: Claude Code native Teams coordination + * - `'taskmaestro+teams'`: TaskMaestro as outer transport, Teams as inner coordination */ async dispatchAgents(input: DispatchAgentsInput): Promise { const context: AgentContext = { @@ -244,73 +258,108 @@ export class AgentService { } } + // Dispatch taskmaestro+teams composable strategy + if (input.executionStrategy === 'taskmaestro+teams' && input.specialists?.length) { + return this.dispatchComposable(input, context, result); + } + // Dispatch taskmaestro strategy if (input.executionStrategy === 'taskmaestro' && input.specialists?.length) { - const uniqueSpecialists = Array.from(new Set(input.specialists)); - const { agents, failedAgents } = await this.loadAgents( - uniqueSpecialists, - context, - true, - input.inlineAgents, - ); - - const assignments: TaskmaestroAssignment[] = agents.map(agent => ({ - name: agent.id, - displayName: agent.displayName, - prompt: this.buildTaskmaestroPrompt(agent, input), - })); - - return { - primaryAgent: result.primaryAgent, - taskmaestro: { - sessionName: `${(input.mode ?? 'eval').toLowerCase()}-specialists`, - paneCount: assignments.length, - assignments, - }, - executionStrategy: 'taskmaestro', - executionHint: this.buildTaskmaestroHint(assignments.length), - failedAgents: failedAgents.length > 0 ? failedAgents : result.failedAgents, - }; + return this.dispatchTaskmaestro(input, context, result); } // Dispatch teams strategy if (input.executionStrategy === 'teams' && input.specialists?.length) { - const uniqueSpecialists = Array.from(new Set(input.specialists)); - const teamName = `${(input.mode ?? 'eval').toLowerCase()}-specialists`; - const { agents, failedAgents } = await this.loadAgents( - uniqueSpecialists, - context, - true, - input.inlineAgents, - ); - - const teammates: TeamsTeammate[] = agents.map(agent => ({ - name: agent.id, - subagent_type: 'general-purpose' as const, - team_name: teamName, - prompt: agent.taskPrompt || `Perform ${agent.displayName} analysis in ${input.mode} mode`, - })); - - return { - primaryAgent: result.primaryAgent, - teams: { - team_name: teamName, - description: `${input.mode} mode specialist team`, - teammates, - }, - executionStrategy: 'teams', - executionHint: this.buildTeamsHint(teamName, teammates.length), - failedAgents: failedAgents.length > 0 ? failedAgents : result.failedAgents, - }; + return this.dispatchTeams(input, context, result); } // Dispatch parallel agents (subagent strategy) + return this.dispatchSubagent(input, context, result); + } + + private async dispatchTaskmaestro( + input: DispatchAgentsInput, + context: AgentContext, + result: DispatchResult, + ): Promise { + const uniqueSpecialists = Array.from(new Set(input.specialists!)); + const { agents, failedAgents } = await this.loadAgents( + uniqueSpecialists, + context, + true, + input.inlineAgents, + ); + + const assignments: TaskmaestroAssignment[] = agents.map(agent => ({ + name: agent.id, + displayName: agent.displayName, + prompt: this.buildTaskmaestroPrompt(agent, input), + })); + + const tmDispatch = { + sessionName: `${(input.mode ?? 'eval').toLowerCase()}-specialists`, + paneCount: assignments.length, + assignments, + }; + + return { + primaryAgent: result.primaryAgent, + taskmaestro: tmDispatch, + executionStrategy: 'taskmaestro', + executionHint: this.buildTaskmaestroHint(assignments.length), + executionPlan: buildSimplePlan(taskmaestroLayer(tmDispatch)), + failedAgents: failedAgents.length > 0 ? failedAgents : result.failedAgents, + }; + } + + private async dispatchTeams( + input: DispatchAgentsInput, + context: AgentContext, + result: DispatchResult, + ): Promise { + const uniqueSpecialists = Array.from(new Set(input.specialists!)); + const teamName = `${(input.mode ?? 'eval').toLowerCase()}-specialists`; + const { agents, failedAgents } = await this.loadAgents( + uniqueSpecialists, + context, + true, + input.inlineAgents, + ); + + const teammates: TeamsTeammate[] = agents.map(agent => ({ + name: agent.id, + subagent_type: 'general-purpose' as const, + team_name: teamName, + prompt: agent.taskPrompt || `Perform ${agent.displayName} analysis in ${input.mode} mode`, + })); + + const teamsConfig = { + team_name: teamName, + description: `${input.mode} mode specialist team`, + teammates, + }; + + return { + primaryAgent: result.primaryAgent, + teams: teamsConfig, + executionStrategy: 'teams', + executionHint: this.buildTeamsHint(teamName, teammates.length), + executionPlan: buildSimplePlan(teamsLayer(teamsConfig)), + failedAgents: failedAgents.length > 0 ? failedAgents : result.failedAgents, + }; + } + + private async dispatchSubagent( + input: DispatchAgentsInput, + context: AgentContext, + result: DispatchResult, + ): Promise { if (input.includeParallel && input.specialists?.length) { const uniqueSpecialists = Array.from(new Set(input.specialists)); const { agents, failedAgents } = await this.loadAgents( uniqueSpecialists, context, - true, // always include full prompt for dispatch + true, input.inlineAgents, ); @@ -332,12 +381,75 @@ export class AgentService { if (failedAgents.length > 0) { result.failedAgents = failedAgents; } + + result.executionPlan = buildSimplePlan(subagentLayer(result.parallelAgents)); } result.executionStrategy = 'subagent'; return result; } + /** + * Dispatch with composable taskmaestro+teams strategy. + * TaskMaestro manages tmux panes (outer), Teams coordinates within panes (inner). + */ + private async dispatchComposable( + input: DispatchAgentsInput, + context: AgentContext, + result: DispatchResult, + ): Promise { + const uniqueSpecialists = Array.from(new Set(input.specialists!)); + const teamName = `${(input.mode ?? 'eval').toLowerCase()}-specialists`; + const { agents, failedAgents } = await this.loadAgents( + uniqueSpecialists, + context, + true, + input.inlineAgents, + ); + + // Build TaskMaestro assignments (outer transport) + const assignments: TaskmaestroAssignment[] = agents.map(agent => ({ + name: agent.id, + displayName: agent.displayName, + prompt: this.buildTaskmaestroPrompt(agent, input), + })); + + const tmDispatch = { + sessionName: teamName, + paneCount: assignments.length, + assignments, + }; + + // Build Teams config (inner coordination) + const teammates: TeamsTeammate[] = agents.map(agent => ({ + name: agent.id, + subagent_type: 'general-purpose' as const, + team_name: teamName, + prompt: agent.taskPrompt || `Perform ${agent.displayName} analysis in ${input.mode} mode`, + })); + + const teamsConfig = { + team_name: teamName, + description: `${input.mode} mode specialist team (coordinated within TaskMaestro panes)`, + teammates, + }; + + const executionPlan: ExecutionPlan = buildNestedPlan( + taskmaestroLayer(tmDispatch), + teamsLayer(teamsConfig), + ); + + return { + primaryAgent: result.primaryAgent, + taskmaestro: tmDispatch, + teams: teamsConfig, + executionStrategy: 'taskmaestro+teams', + executionHint: this.buildComposableHint(teamName, assignments.length), + executionPlan, + failedAgents: failedAgents.length > 0 ? failedAgents : result.failedAgents, + }; + } + private buildTaskmaestroPrompt(agent: PreparedAgent, input: DispatchAgentsInput): string { const taskContext = input.taskDescription ? `\n\n**Task:** ${input.taskDescription}` : ''; const fileContext = input.targetFiles?.length @@ -375,6 +487,20 @@ When done, provide a summary of all findings.`; 6. Shutdown teammates via SendMessage with shutdown_request`; } + private buildComposableHint(teamName: string, paneCount: number): string { + return `Composable execution (TaskMaestro + Teams): +Outer — TaskMaestro manages ${paneCount} tmux pane(s): + 1. /taskmaestro start --panes ${paneCount} + 2. Assign each pane its specialist prompt +Inner — Teams coordinates within panes: + 1. Each pane worker uses TeamCreate: { team_name: "${teamName}" } + 2. Workers share task list for coordination + 3. Monitor via TaskList and /taskmaestro status +Teardown: + 1. Shutdown teammates via SendMessage with shutdown_request + 2. /taskmaestro stop all`; + } + /** * Resolve an agent by name using the unified registry. * Resolution order: inline > custom local > primary (override chain). diff --git a/apps/mcp-server/src/agent/agent.types.spec.ts b/apps/mcp-server/src/agent/agent.types.spec.ts index 72a6bcd0..fe80c9b9 100644 --- a/apps/mcp-server/src/agent/agent.types.spec.ts +++ b/apps/mcp-server/src/agent/agent.types.spec.ts @@ -4,6 +4,11 @@ import type { TaskmaestroDispatch, DispatchAgentsInput, DispatchResult, + ExecutionPlan, + ExecutionLayer, + SubagentLayer, + TaskmaestroLayer, + TeamsLayer, } from './agent.types'; describe('agent.types - TaskMaestro types', () => { @@ -78,5 +83,93 @@ describe('agent.types - TaskMaestro types', () => { expect(result.taskmaestro).toBeUndefined(); expect(result.executionStrategy).toBeUndefined(); }); + + it('accepts optional executionPlan', () => { + const result: DispatchResult = { + executionHint: 'Use composable execution', + executionPlan: { + outerExecution: { + type: 'taskmaestro', + config: { sessionName: 'ws-1', paneCount: 2, assignments: [] }, + }, + innerCoordination: { + type: 'teams', + config: { team_name: 'ws-1', description: 'team', teammates: [] }, + }, + }, + }; + expect(result.executionPlan?.outerExecution.type).toBe('taskmaestro'); + expect(result.executionPlan?.innerCoordination?.type).toBe('teams'); + }); + }); + + describe('ExecutionLayer discriminated union', () => { + it('SubagentLayer has type subagent', () => { + const layer: SubagentLayer = { type: 'subagent', agents: [] }; + expect(layer.type).toBe('subagent'); + }); + + it('TaskmaestroLayer has type taskmaestro', () => { + const layer: TaskmaestroLayer = { + type: 'taskmaestro', + config: { sessionName: 'ws', paneCount: 1, assignments: [] }, + }; + expect(layer.type).toBe('taskmaestro'); + }); + + it('TeamsLayer has type teams', () => { + const layer: TeamsLayer = { + type: 'teams', + config: { team_name: 'test', description: 'test', teammates: [] }, + }; + expect(layer.type).toBe('teams'); + }); + + it('ExecutionLayer narrows correctly by type', () => { + const layer: ExecutionLayer = { + type: 'taskmaestro', + config: { sessionName: 'ws', paneCount: 1, assignments: [] }, + }; + + if (layer.type === 'taskmaestro') { + expect(layer.config.sessionName).toBe('ws'); + } + }); + }); + + describe('ExecutionPlan', () => { + it('simple plan has only outerExecution', () => { + const plan: ExecutionPlan = { + outerExecution: { type: 'subagent', agents: [] }, + }; + expect(plan.outerExecution.type).toBe('subagent'); + expect(plan.innerCoordination).toBeUndefined(); + }); + + it('nested plan has outerExecution and innerCoordination', () => { + const plan: ExecutionPlan = { + outerExecution: { + type: 'taskmaestro', + config: { sessionName: 'ws', paneCount: 2, assignments: [] }, + }, + innerCoordination: { + type: 'teams', + config: { team_name: 'ws', description: 'team', teammates: [] }, + }, + }; + expect(plan.outerExecution.type).toBe('taskmaestro'); + expect(plan.innerCoordination?.type).toBe('teams'); + }); + }); + + describe('DispatchAgentsInput - composable strategy', () => { + it('accepts executionStrategy: taskmaestro+teams', () => { + const input: DispatchAgentsInput = { + mode: 'EVAL', + executionStrategy: 'taskmaestro+teams', + specialists: ['security-specialist'], + }; + expect(input.executionStrategy).toBe('taskmaestro+teams'); + }); }); }); diff --git a/apps/mcp-server/src/agent/agent.types.ts b/apps/mcp-server/src/agent/agent.types.ts index ffbc7887..3913fedb 100644 --- a/apps/mcp-server/src/agent/agent.types.ts +++ b/apps/mcp-server/src/agent/agent.types.ts @@ -145,6 +145,59 @@ export interface VisibilityConfig { messages?: VisibilityMessages; } +/** + * Execution layer: subagent strategy + */ +export interface SubagentLayer { + type: 'subagent'; + agents: DispatchedAgent[]; +} + +/** + * Execution layer: TaskMaestro tmux-based transport + */ +export interface TaskmaestroLayer { + type: 'taskmaestro'; + config: TaskmaestroDispatch; +} + +/** + * Execution layer: Claude Code native Teams coordination + */ +export interface TeamsLayer { + type: 'teams'; + config: TeamsDispatch; + visibility?: VisibilityConfig; +} + +/** + * Discriminated union of all execution layer types. + * Each layer represents a single execution strategy. + */ +export type ExecutionLayer = SubagentLayer | TaskmaestroLayer | TeamsLayer; + +/** + * Composable execution plan with outer transport and optional inner coordination. + * + * Simple plan: outerExecution only (e.g. subagent, taskmaestro, or teams) + * Nested plan: outerExecution + innerCoordination (e.g. taskmaestro + teams) + * + * @example + * // Simple subagent plan + * { outerExecution: { type: 'subagent', agents: [...] } } + * + * @example + * // Nested: TaskMaestro as transport, Teams as coordination + * { + * outerExecution: { type: 'taskmaestro', config: {...} }, + * innerCoordination: { type: 'teams', config: {...} } + * } + */ +export interface ExecutionPlan { + outerExecution: ExecutionLayer; + innerCoordination?: ExecutionLayer; +} + /** * Result of dispatching agents for execution */ @@ -162,6 +215,8 @@ export interface DispatchResult { executionStrategy?: string; /** Real-time specialist execution visibility configuration */ visibility?: VisibilityConfig; + /** Composable execution plan (always populated when agents are dispatched) */ + executionPlan?: ExecutionPlan; } /** @@ -208,8 +263,8 @@ export interface DispatchAgentsInput { primaryAgent?: string; /** Agent stack name to resolve primary + specialists from a preset */ agentStack?: string; - /** Execution strategy: 'subagent' (default), 'taskmaestro', or 'teams' */ - executionStrategy?: 'subagent' | 'taskmaestro' | 'teams'; + /** Execution strategy: 'subagent' (default), 'taskmaestro', 'teams', or composable 'taskmaestro+teams' */ + executionStrategy?: 'subagent' | 'taskmaestro' | 'teams' | 'taskmaestro+teams'; /** Inline agent definitions keyed by agent ID, highest priority in resolution */ inlineAgents?: Record; } diff --git a/apps/mcp-server/src/agent/execution-plan.spec.ts b/apps/mcp-server/src/agent/execution-plan.spec.ts new file mode 100644 index 00000000..294f33cf --- /dev/null +++ b/apps/mcp-server/src/agent/execution-plan.spec.ts @@ -0,0 +1,243 @@ +import { describe, it, expect } from 'vitest'; +import { + buildSimplePlan, + buildNestedPlan, + isNestedPlan, + subagentLayer, + taskmaestroLayer, + teamsLayer, + serializeExecutionPlan, +} from './execution-plan'; +import type { + DispatchedAgent, + TaskmaestroDispatch, + TeamsDispatch, + ExecutionPlan, +} from './agent.types'; + +describe('execution-plan', () => { + const mockAgent: DispatchedAgent = { + name: 'security-specialist', + displayName: 'Security Specialist', + description: 'Security analysis', + dispatchParams: { + subagent_type: 'general-purpose', + prompt: 'Analyze security', + description: 'Security analysis', + run_in_background: true, + }, + }; + + const mockTmDispatch: TaskmaestroDispatch = { + sessionName: 'eval-specialists', + paneCount: 2, + assignments: [ + { + name: 'security-specialist', + displayName: 'Security Specialist', + prompt: 'Review security', + }, + { name: 'perf-specialist', displayName: 'Performance Specialist', prompt: 'Review perf' }, + ], + }; + + const mockTeamsConfig: TeamsDispatch = { + team_name: 'eval-specialists', + description: 'EVAL mode specialist team', + teammates: [ + { + name: 'security-specialist', + subagent_type: 'general-purpose', + team_name: 'eval-specialists', + prompt: 'Review security', + }, + ], + }; + + describe('layer factories', () => { + it('subagentLayer creates a subagent layer', () => { + const layer = subagentLayer([mockAgent]); + expect(layer.type).toBe('subagent'); + if (layer.type === 'subagent') { + expect(layer.agents).toHaveLength(1); + expect(layer.agents[0].name).toBe('security-specialist'); + } + }); + + it('taskmaestroLayer creates a taskmaestro layer', () => { + const layer = taskmaestroLayer(mockTmDispatch); + expect(layer.type).toBe('taskmaestro'); + if (layer.type === 'taskmaestro') { + expect(layer.config.sessionName).toBe('eval-specialists'); + expect(layer.config.paneCount).toBe(2); + } + }); + + it('teamsLayer creates a teams layer', () => { + const layer = teamsLayer(mockTeamsConfig); + expect(layer.type).toBe('teams'); + if (layer.type === 'teams') { + expect(layer.config.team_name).toBe('eval-specialists'); + } + }); + + it('teamsLayer includes visibility when provided', () => { + const visibility = { reportTo: 'lead', format: 'markdown', includeProgress: true }; + const layer = teamsLayer(mockTeamsConfig, visibility); + expect(layer.type).toBe('teams'); + if (layer.type === 'teams') { + expect(layer.visibility).toEqual(visibility); + } + }); + + it('teamsLayer omits visibility when not provided', () => { + const layer = teamsLayer(mockTeamsConfig); + if (layer.type === 'teams') { + expect(layer.visibility).toBeUndefined(); + } + }); + }); + + describe('buildSimplePlan', () => { + it('creates a plan with only outerExecution', () => { + const plan = buildSimplePlan(subagentLayer([mockAgent])); + expect(plan.outerExecution.type).toBe('subagent'); + expect(plan.innerCoordination).toBeUndefined(); + }); + + it('works with taskmaestro layer', () => { + const plan = buildSimplePlan(taskmaestroLayer(mockTmDispatch)); + expect(plan.outerExecution.type).toBe('taskmaestro'); + expect(plan.innerCoordination).toBeUndefined(); + }); + + it('works with teams layer', () => { + const plan = buildSimplePlan(teamsLayer(mockTeamsConfig)); + expect(plan.outerExecution.type).toBe('teams'); + expect(plan.innerCoordination).toBeUndefined(); + }); + }); + + describe('buildNestedPlan', () => { + it('creates a plan with outer and inner layers', () => { + const plan = buildNestedPlan(taskmaestroLayer(mockTmDispatch), teamsLayer(mockTeamsConfig)); + expect(plan.outerExecution.type).toBe('taskmaestro'); + expect(plan.innerCoordination).toBeDefined(); + expect(plan.innerCoordination!.type).toBe('teams'); + }); + + it('supports any combination of layers', () => { + const plan = buildNestedPlan(teamsLayer(mockTeamsConfig), subagentLayer([mockAgent])); + expect(plan.outerExecution.type).toBe('teams'); + expect(plan.innerCoordination!.type).toBe('subagent'); + }); + }); + + describe('isNestedPlan', () => { + it('returns false for simple plans', () => { + const plan = buildSimplePlan(subagentLayer([mockAgent])); + expect(isNestedPlan(plan)).toBe(false); + }); + + it('returns true for nested plans', () => { + const plan = buildNestedPlan(taskmaestroLayer(mockTmDispatch), teamsLayer(mockTeamsConfig)); + expect(isNestedPlan(plan)).toBe(true); + }); + }); + + describe('serializeExecutionPlan', () => { + it('serializes a simple subagent plan', () => { + const plan = buildSimplePlan(subagentLayer([mockAgent])); + const serialized = serializeExecutionPlan(plan); + + expect(serialized.strategy).toBe('subagent'); + expect(serialized.isNested).toBe(false); + expect(serialized.outerExecution).toBeDefined(); + expect(serialized.innerCoordination).toBeUndefined(); + + const outer = serialized.outerExecution as Record; + expect(outer.type).toBe('subagent'); + expect(outer.agentCount).toBe(1); + }); + + it('serializes a simple taskmaestro plan', () => { + const plan = buildSimplePlan(taskmaestroLayer(mockTmDispatch)); + const serialized = serializeExecutionPlan(plan); + + expect(serialized.strategy).toBe('taskmaestro'); + expect(serialized.isNested).toBe(false); + + const outer = serialized.outerExecution as Record; + expect(outer.type).toBe('taskmaestro'); + expect(outer.sessionName).toBe('eval-specialists'); + expect(outer.paneCount).toBe(2); + }); + + it('serializes a simple teams plan', () => { + const plan = buildSimplePlan(teamsLayer(mockTeamsConfig)); + const serialized = serializeExecutionPlan(plan); + + expect(serialized.strategy).toBe('teams'); + const outer = serialized.outerExecution as Record; + expect(outer.type).toBe('teams'); + expect(outer.teamName).toBe('eval-specialists'); + expect(outer.teammateCount).toBe(1); + }); + + it('serializes a nested taskmaestro+teams plan', () => { + const plan = buildNestedPlan(taskmaestroLayer(mockTmDispatch), teamsLayer(mockTeamsConfig)); + const serialized = serializeExecutionPlan(plan); + + expect(serialized.strategy).toBe('taskmaestro+teams'); + expect(serialized.isNested).toBe(true); + expect(serialized.outerExecution).toBeDefined(); + expect(serialized.innerCoordination).toBeDefined(); + + const outer = serialized.outerExecution as Record; + expect(outer.type).toBe('taskmaestro'); + + const inner = serialized.innerCoordination as Record; + expect(inner.type).toBe('teams'); + }); + + it('includes visibility in teams layer serialization when present', () => { + const visibility = { reportTo: 'lead', format: 'markdown', includeProgress: true }; + const plan = buildSimplePlan(teamsLayer(mockTeamsConfig, visibility)); + const serialized = serializeExecutionPlan(plan); + + const outer = serialized.outerExecution as Record; + expect(outer.visibility).toEqual(visibility); + }); + + it('omits visibility in teams layer serialization when absent', () => { + const plan = buildSimplePlan(teamsLayer(mockTeamsConfig)); + const serialized = serializeExecutionPlan(plan); + + const outer = serialized.outerExecution as Record; + expect(outer.visibility).toBeUndefined(); + }); + + it('serialized plan is JSON-safe', () => { + const plan = buildNestedPlan(taskmaestroLayer(mockTmDispatch), teamsLayer(mockTeamsConfig)); + const serialized = serializeExecutionPlan(plan); + + const json = JSON.stringify(serialized); + const parsed = JSON.parse(json); + expect(parsed.strategy).toBe('taskmaestro+teams'); + expect(parsed.isNested).toBe(true); + }); + }); + + describe('backward compatibility', () => { + it('simple plans match single-strategy shape', () => { + const plan: ExecutionPlan = { outerExecution: { type: 'subagent', agents: [mockAgent] } }; + expect(plan.outerExecution.type).toBe('subagent'); + expect(plan.innerCoordination).toBeUndefined(); + }); + + it('ExecutionPlan is assignable with just outerExecution', () => { + const plan: ExecutionPlan = buildSimplePlan(taskmaestroLayer(mockTmDispatch)); + expect(isNestedPlan(plan)).toBe(false); + }); + }); +}); diff --git a/apps/mcp-server/src/agent/execution-plan.ts b/apps/mcp-server/src/agent/execution-plan.ts new file mode 100644 index 00000000..86f1a83f --- /dev/null +++ b/apps/mcp-server/src/agent/execution-plan.ts @@ -0,0 +1,108 @@ +import type { + ExecutionPlan, + ExecutionLayer, + DispatchedAgent, + TaskmaestroDispatch, + TeamsDispatch, + VisibilityConfig, +} from './agent.types'; + +/** + * Build a single-layer execution plan. + */ +export function buildSimplePlan(layer: ExecutionLayer): ExecutionPlan { + return { outerExecution: layer }; +} + +/** + * Build a nested execution plan with outer transport and inner coordination. + */ +export function buildNestedPlan(outer: ExecutionLayer, inner: ExecutionLayer): ExecutionPlan { + return { outerExecution: outer, innerCoordination: inner }; +} + +/** + * Check whether an execution plan uses inner coordination. + */ +export function isNestedPlan(plan: ExecutionPlan): boolean { + return plan.innerCoordination !== undefined; +} + +/** + * Create a subagent execution layer. + */ +export function subagentLayer(agents: DispatchedAgent[]): ExecutionLayer { + return { type: 'subagent', agents }; +} + +/** + * Create a TaskMaestro execution layer. + */ +export function taskmaestroLayer(config: TaskmaestroDispatch): ExecutionLayer { + return { type: 'taskmaestro', config }; +} + +/** + * Create a Teams execution layer. + */ +export function teamsLayer(config: TeamsDispatch, visibility?: VisibilityConfig): ExecutionLayer { + return { type: 'teams', config, ...(visibility ? { visibility } : {}) }; +} + +/** + * Serialize an ExecutionPlan to a flat, JSON-safe structure for handler consumption. + * + * The serialized form includes: + * - `strategy`: simple name or composite like "taskmaestro+teams" + * - `isNested`: whether inner coordination is present + * - `outerExecution`: serialized outer layer + * - `innerCoordination`: serialized inner layer (if present) + */ +export function serializeExecutionPlan(plan: ExecutionPlan): Record { + const serialized: Record = { + outerExecution: serializeLayer(plan.outerExecution), + isNested: isNestedPlan(plan), + strategy: plan.innerCoordination + ? `${plan.outerExecution.type}+${plan.innerCoordination.type}` + : plan.outerExecution.type, + }; + + if (plan.innerCoordination) { + serialized.innerCoordination = serializeLayer(plan.innerCoordination); + } + + return serialized; +} + +function serializeLayer(layer: ExecutionLayer): Record { + switch (layer.type) { + case 'subagent': + return { + type: 'subagent', + agentCount: layer.agents.length, + agents: layer.agents.map(a => ({ + name: a.name, + displayName: a.displayName, + description: a.description, + })), + }; + case 'taskmaestro': + return { + type: 'taskmaestro', + sessionName: layer.config.sessionName, + paneCount: layer.config.paneCount, + assignments: layer.config.assignments.map(a => ({ + name: a.name, + displayName: a.displayName, + })), + }; + case 'teams': + return { + type: 'teams', + teamName: layer.config.team_name, + teammateCount: layer.config.teammates.length, + teammates: layer.config.teammates.map(t => ({ name: t.name })), + ...(layer.visibility ? { visibility: layer.visibility } : {}), + }; + } +} diff --git a/apps/mcp-server/src/agent/index.ts b/apps/mcp-server/src/agent/index.ts index c91331cb..a4a3da0a 100644 --- a/apps/mcp-server/src/agent/index.ts +++ b/apps/mcp-server/src/agent/index.ts @@ -6,3 +6,4 @@ export * from './agent.types'; export * from './agent-prompt.builder'; export * from './agent-stack.schema'; export * from './agent-stack.loader'; +export * from './execution-plan'; diff --git a/apps/mcp-server/src/mcp/handlers/agent.handler.spec.ts b/apps/mcp-server/src/mcp/handlers/agent.handler.spec.ts index f1f04cc7..eabb515e 100644 --- a/apps/mcp-server/src/mcp/handlers/agent.handler.spec.ts +++ b/apps/mcp-server/src/mcp/handlers/agent.handler.spec.ts @@ -756,7 +756,7 @@ describe('AgentHandler', () => { ]); }); - it('should include teams in executionStrategy enum', () => { + it('should include all strategies in executionStrategy enum', () => { const definitions = handler.getToolDefinitions(); const dispatchAgents = definitions.find(d => d.name === 'dispatch_agents'); const strategyEnum = ( @@ -765,6 +765,7 @@ describe('AgentHandler', () => { expect(strategyEnum).toContain('teams'); expect(strategyEnum).toContain('subagent'); expect(strategyEnum).toContain('taskmaestro'); + expect(strategyEnum).toContain('taskmaestro+teams'); }); it('should have correct required parameters', () => { diff --git a/apps/mcp-server/src/mcp/handlers/agent.handler.ts b/apps/mcp-server/src/mcp/handlers/agent.handler.ts index 2d80a167..37b4a6f2 100644 --- a/apps/mcp-server/src/mcp/handlers/agent.handler.ts +++ b/apps/mcp-server/src/mcp/handlers/agent.handler.ts @@ -176,9 +176,9 @@ export class AgentHandler extends AbstractHandler { }, executionStrategy: { type: 'string', - enum: ['subagent', 'taskmaestro', 'teams'], + enum: ['subagent', 'taskmaestro', 'teams', 'taskmaestro+teams'], description: - 'Execution strategy for specialist agents. "subagent" (default) uses Claude Code Agent tool with run_in_background. "taskmaestro" returns tmux pane assignments for /taskmaestro skill. "teams" uses Claude Code native teams with shared TaskList coordination.', + 'Execution strategy for specialist agents. "subagent" (default) uses Claude Code Agent tool with run_in_background. "taskmaestro" returns tmux pane assignments for /taskmaestro skill. "teams" uses Claude Code native teams with shared TaskList coordination. "taskmaestro+teams" uses TaskMaestro as outer transport with Teams as inner coordination.', }, agentStack: { type: 'string', @@ -247,7 +247,12 @@ export class AgentHandler extends AbstractHandler { const taskDescription = extractOptionalString(args, 'taskDescription'); let includeParallel = args?.includeParallel === true; const executionStrategy = - (args?.executionStrategy as 'subagent' | 'taskmaestro' | 'teams' | undefined) ?? 'subagent'; + (args?.executionStrategy as + | 'subagent' + | 'taskmaestro' + | 'teams' + | 'taskmaestro+teams' + | undefined) ?? 'subagent'; const inlineAgents = this.extractInlineAgents(args); // Resolve agent stack if provided