mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Added Copilot Studio Agent (#722)
* Small fix in dotnet conformance tests * Added CopilotStudioAgent implementation * Added examples * Updated package README * Small fixes * Small improvements * Fixed dotnet tests * Add unit tests * Updated tests * Small updates * Small test fixes * Revert "Small test fixes" This reverts commit983ac44a70. * Small fixes in documentation * Updated test configuration * Revert "Updated test configuration" This reverts commit2a16fea815. * Small fix * Reverted TODO item * Small suppressions * More fixes * Small fixes * Fixed tests * Removed disallow_any_unimported rule in all packages * Fixes
This commit is contained in:
committed by
GitHub
Unverified
parent
74879489a4
commit
e7cd03b32e
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
@@ -0,0 +1,97 @@
|
||||
# Get Started with Microsoft Agent Framework Copilot Studio
|
||||
|
||||
Please install this package as the extra for `agent-framework`:
|
||||
|
||||
```bash
|
||||
pip install agent-framework[copilotstudio]
|
||||
```
|
||||
|
||||
## Copilot Studio Agent
|
||||
|
||||
The Copilot Studio agent enables integration with Microsoft Copilot Studio, allowing you to interact with published copilots through the Agent Framework.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before using the Copilot Studio agent, you need:
|
||||
|
||||
1. **Copilot Studio Environment**: Access to a Microsoft Copilot Studio environment with a published copilot
|
||||
2. **App Registration**: An Azure AD App Registration with appropriate permissions for Power Platform API
|
||||
3. **Environment Configuration**: Set the required environment variables or pass them as parameters
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The following environment variables are used for configuration:
|
||||
|
||||
- `COPILOTSTUDIOAGENT__ENVIRONMENTID` - Your Copilot Studio environment ID
|
||||
- `COPILOTSTUDIOAGENT__SCHEMANAME` - Your copilot's agent identifier/schema name
|
||||
- `COPILOTSTUDIOAGENT__AGENTAPPID` - Your App Registration client ID
|
||||
- `COPILOTSTUDIOAGENT__TENANTID` - Your Azure AD tenant ID
|
||||
|
||||
### Basic Usage Example
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from agent_framework.copilotstudio import CopilotStudioAgent
|
||||
|
||||
async def main():
|
||||
# Create agent using environment variables
|
||||
agent = CopilotStudioAgent()
|
||||
|
||||
# Run a simple query
|
||||
result = await agent.run("What is the capital of France?")
|
||||
print(result)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### Explicit Configuration Example
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import os
|
||||
from agent_framework.copilotstudio import CopilotStudioAgent, acquire_token
|
||||
from microsoft_agents.copilotstudio.client import ConnectionSettings, CopilotClient, PowerPlatformCloud, AgentType
|
||||
|
||||
async def main():
|
||||
# Acquire authentication token
|
||||
token = acquire_token(
|
||||
client_id=os.environ["COPILOTSTUDIOAGENT__AGENTAPPID"],
|
||||
tenant_id=os.environ["COPILOTSTUDIOAGENT__TENANTID"]
|
||||
)
|
||||
|
||||
# Create connection settings
|
||||
settings = ConnectionSettings(
|
||||
environment_id=os.environ["COPILOTSTUDIOAGENT__ENVIRONMENTID"],
|
||||
agent_identifier=os.environ["COPILOTSTUDIOAGENT__SCHEMANAME"],
|
||||
cloud=PowerPlatformCloud.PROD,
|
||||
copilot_agent_type=AgentType.PUBLISHED
|
||||
)
|
||||
|
||||
# Create client and agent
|
||||
client = CopilotClient(settings=settings, token=token)
|
||||
agent = CopilotStudioAgent(client=client)
|
||||
|
||||
# Run a query
|
||||
result = await agent.run("What is the capital of Italy?")
|
||||
print(result)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
The package uses MSAL (Microsoft Authentication Library) for authentication with interactive flows when needed. Ensure your App Registration has:
|
||||
|
||||
- **API Permissions**: Power Platform API permissions (https://api.powerplatform.com/.default)
|
||||
- **Redirect URIs**: Configured appropriately for your authentication method
|
||||
- **Public Client Flows**: Enabled if using interactive authentication
|
||||
|
||||
### Examples
|
||||
|
||||
For more comprehensive examples, see the [Copilot Studio examples](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/agents/copilotstudio/) which demonstrate:
|
||||
|
||||
- Basic non-streaming and streaming execution
|
||||
- Explicit settings and manual token acquisition
|
||||
- Different authentication patterns
|
||||
- Error handling and troubleshooting
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._acquire_token import acquire_token
|
||||
from ._agent import CopilotStudioAgent
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0" # Fallback for development mode
|
||||
|
||||
__all__ = ["CopilotStudioAgent", "__version__", "acquire_token"]
|
||||
@@ -0,0 +1,94 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
# pyright: reportUnknownMemberType = false
|
||||
# pyright: reportUnknownVariableType = false
|
||||
# pyright: reportUnknownArgumentType = false
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agent_framework.exceptions import ServiceException
|
||||
from msal import PublicClientApplication
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default scopes for Power Platform API
|
||||
DEFAULT_SCOPES = ["https://api.powerplatform.com/.default"]
|
||||
|
||||
|
||||
def acquire_token(
|
||||
client_id: str,
|
||||
tenant_id: str,
|
||||
username: str | None = None,
|
||||
token_cache: Any | None = None,
|
||||
scopes: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Acquire an authentication token using MSAL Public Client Application.
|
||||
|
||||
This function attempts to acquire a token silently first (using cached tokens),
|
||||
and falls back to interactive authentication if needed.
|
||||
|
||||
Args:
|
||||
client_id: The client ID of the application.
|
||||
tenant_id: The tenant ID for authentication.
|
||||
username: Optional username to filter accounts.
|
||||
token_cache: Optional token cache for storing tokens.
|
||||
scopes: Optional list of scopes. Defaults to Power Platform API scopes.
|
||||
|
||||
Returns:
|
||||
The access token string.
|
||||
|
||||
Raises:
|
||||
ServiceException: If authentication token cannot be acquired.
|
||||
"""
|
||||
if not client_id:
|
||||
raise ServiceException("Client ID is required for token acquisition.")
|
||||
|
||||
if not tenant_id:
|
||||
raise ServiceException("Tenant ID is required for token acquisition.")
|
||||
|
||||
authority = f"https://login.microsoftonline.com/{tenant_id}"
|
||||
target_scopes = scopes or DEFAULT_SCOPES
|
||||
|
||||
pca = PublicClientApplication(client_id=client_id, authority=authority, token_cache=token_cache)
|
||||
|
||||
accounts = pca.get_accounts(username=username)
|
||||
|
||||
token: str | None = None
|
||||
|
||||
# Try silent token acquisition first if we have cached accounts
|
||||
if accounts:
|
||||
try:
|
||||
logger.debug("Attempting silent token acquisition")
|
||||
response = pca.acquire_token_silent(scopes=target_scopes, account=accounts[0])
|
||||
if response and "access_token" in response:
|
||||
token = str(response["access_token"]) # type: ignore[assignment]
|
||||
logger.debug("Successfully acquired token silently")
|
||||
elif response and "error" in response:
|
||||
logger.warning(
|
||||
"Silent token acquisition failed: %s - %s", response.get("error"), response.get("error_description")
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.warning("Silent token acquisition failed with exception: %s", ex)
|
||||
|
||||
# Fall back to interactive authentication if silent acquisition failed
|
||||
if not token:
|
||||
try:
|
||||
logger.debug("Attempting interactive token acquisition")
|
||||
response = pca.acquire_token_interactive(scopes=target_scopes)
|
||||
if response and "access_token" in response:
|
||||
token = str(response["access_token"]) # type: ignore[assignment]
|
||||
logger.debug("Successfully acquired token interactively")
|
||||
elif response and "error" in response:
|
||||
logger.error(
|
||||
"Interactive token acquisition failed: %s - %s",
|
||||
response.get("error"),
|
||||
response.get("error_description"),
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.error("Interactive token acquisition failed with exception: %s", ex)
|
||||
raise ServiceException(f"Failed to acquire authentication token: {ex}") from ex
|
||||
|
||||
if not token:
|
||||
raise ServiceException("Authentication token cannot be acquired.")
|
||||
|
||||
return token
|
||||
@@ -0,0 +1,312 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import AsyncIterable
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from agent_framework import (
|
||||
AgentRunResponse,
|
||||
AgentRunResponseUpdate,
|
||||
AgentThread,
|
||||
BaseAgent,
|
||||
ChatMessage,
|
||||
Role,
|
||||
TextContent,
|
||||
)
|
||||
from agent_framework._pydantic import AFBaseSettings
|
||||
from agent_framework.exceptions import ServiceException, ServiceInitializationError
|
||||
from microsoft_agents.copilotstudio.client import AgentType, ConnectionSettings, CopilotClient, PowerPlatformCloud
|
||||
from pydantic import ValidationError
|
||||
|
||||
from ._acquire_token import acquire_token
|
||||
|
||||
|
||||
class CopilotStudioSettings(AFBaseSettings):
|
||||
"""Copilot Studio model settings.
|
||||
|
||||
The settings are first loaded from environment variables with the prefix 'COPILOTSTUDIOAGENT__'.
|
||||
If the environment variables are not found, the settings can be loaded from a .env file
|
||||
with the encoding 'utf-8'. If the settings are not found in the .env file, the settings
|
||||
are ignored; however, validation will fail alerting that the settings are missing.
|
||||
|
||||
Attributes:
|
||||
environmentid: Environment ID of environment with the Copilot Studio App..
|
||||
(Env var COPILOTSTUDIOAGENT__ENVIRONMENTID)
|
||||
schemaname: The agent identifier or schema name of the Copilot to use.
|
||||
(Env var COPILOTSTUDIOAGENT__SCHEMANAME)
|
||||
agentappid: The app ID of the App Registration used to login.
|
||||
(Env var COPILOTSTUDIOAGENT__AGENTAPPID)
|
||||
tenantid: The tenant ID of the App Registration used to login.
|
||||
(Env var COPILOTSTUDIOAGENT__TENANTID)
|
||||
Parameters:
|
||||
env_file_path: If provided, the .env settings are read from this file path location.
|
||||
env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
|
||||
"""
|
||||
|
||||
env_prefix: ClassVar[str] = "COPILOTSTUDIOAGENT__"
|
||||
|
||||
environmentid: str | None = None
|
||||
schemaname: str | None = None
|
||||
agentappid: str | None = None
|
||||
tenantid: str | None = None
|
||||
|
||||
|
||||
class CopilotStudioAgent(BaseAgent):
|
||||
"""A Copilot Studio Agent."""
|
||||
|
||||
client: CopilotClient
|
||||
settings: ConnectionSettings | None
|
||||
token: str | None
|
||||
cloud: PowerPlatformCloud | None
|
||||
agent_type: AgentType | None
|
||||
custom_power_platform_cloud: str | None
|
||||
username: str | None
|
||||
token_cache: Any | None
|
||||
scopes: list[str] | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: CopilotClient | None = None,
|
||||
settings: ConnectionSettings | None = None,
|
||||
environment_id: str | None = None,
|
||||
agent_identifier: str | None = None,
|
||||
client_id: str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
token: str | None = None,
|
||||
cloud: PowerPlatformCloud | None = None,
|
||||
agent_type: AgentType | None = None,
|
||||
custom_power_platform_cloud: str | None = None,
|
||||
username: str | None = None,
|
||||
token_cache: Any | None = None,
|
||||
scopes: list[str] | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the Copilot Studio Agent.
|
||||
|
||||
Args:
|
||||
client: Optional pre-configured CopilotClient instance. If not provided,
|
||||
a new client will be created using the other parameters.
|
||||
settings: Optional pre-configured ConnectionSettings. If not provided,
|
||||
settings will be created from the other parameters.
|
||||
environment_id: Environment ID of the Power Platform environment containing
|
||||
the Copilot Studio app. Can also be set via COPILOTSTUDIOAGENT__ENVIRONMENTID
|
||||
environment variable.
|
||||
agent_identifier: The agent identifier or schema name of the Copilot to use.
|
||||
Can also be set via COPILOTSTUDIOAGENT__SCHEMANAME environment variable.
|
||||
client_id: The app ID of the App Registration used for authentication.
|
||||
Can also be set via COPILOTSTUDIOAGENT__AGENTAPPID environment variable.
|
||||
tenant_id: The tenant ID of the App Registration used for authentication.
|
||||
Can also be set via COPILOTSTUDIOAGENT__TENANTID environment variable.
|
||||
token: Optional pre-acquired authentication token. If not provided,
|
||||
token acquisition will be attempted using MSAL.
|
||||
cloud: The Power Platform cloud to use (Public, GCC, etc.).
|
||||
agent_type: The type of Copilot Studio agent (Copilot, Agent, etc.).
|
||||
custom_power_platform_cloud: Custom Power Platform cloud URL if using
|
||||
a custom environment.
|
||||
username: Optional username for token acquisition.
|
||||
token_cache: Optional token cache for storing authentication tokens.
|
||||
scopes: Optional list of authentication scopes. Defaults to Power Platform
|
||||
API scopes if not provided.
|
||||
env_file_path: Optional path to .env file for loading configuration.
|
||||
env_file_encoding: Encoding of the .env file, defaults to 'utf-8'.
|
||||
|
||||
Raises:
|
||||
ServiceInitializationError: If required configuration is missing or invalid.
|
||||
"""
|
||||
if not client:
|
||||
try:
|
||||
copilot_studio_settings = CopilotStudioSettings(
|
||||
environmentid=environment_id,
|
||||
schemaname=agent_identifier,
|
||||
agentappid=client_id,
|
||||
tenantid=tenant_id,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
)
|
||||
except ValidationError as ex:
|
||||
raise ServiceInitializationError("Failed to create Copilot Studio settings.", ex) from ex
|
||||
|
||||
if not settings:
|
||||
if not copilot_studio_settings.environmentid:
|
||||
raise ServiceInitializationError(
|
||||
"Copilot Studio environment ID is required. Set via 'environment_id' parameter "
|
||||
"or 'COPILOTSTUDIOAGENT__ENVIRONMENTID' environment variable."
|
||||
)
|
||||
if not copilot_studio_settings.schemaname:
|
||||
raise ServiceInitializationError(
|
||||
"Copilot Studio agent identifier/schema name is required. Set via 'agent_identifier' parameter "
|
||||
"or 'COPILOTSTUDIOAGENT__SCHEMANAME' environment variable."
|
||||
)
|
||||
|
||||
settings = ConnectionSettings(
|
||||
environment_id=copilot_studio_settings.environmentid,
|
||||
agent_identifier=copilot_studio_settings.schemaname,
|
||||
cloud=cloud,
|
||||
copilot_agent_type=agent_type,
|
||||
custom_power_platform_cloud=custom_power_platform_cloud,
|
||||
)
|
||||
|
||||
if not token:
|
||||
if not copilot_studio_settings.agentappid:
|
||||
raise ServiceInitializationError(
|
||||
"Copilot Studio client ID is required. Set via 'client_id' parameter "
|
||||
"or 'COPILOTSTUDIOAGENT__AGENTAPPID' environment variable."
|
||||
)
|
||||
|
||||
if not copilot_studio_settings.tenantid:
|
||||
raise ServiceInitializationError(
|
||||
"Copilot Studio tenant ID is required. Set via 'tenant_id' parameter "
|
||||
"or 'COPILOTSTUDIOAGENT__TENANTID' environment variable."
|
||||
)
|
||||
|
||||
token = acquire_token(
|
||||
client_id=copilot_studio_settings.agentappid,
|
||||
tenant_id=copilot_studio_settings.tenantid,
|
||||
username=username,
|
||||
token_cache=token_cache,
|
||||
scopes=scopes,
|
||||
)
|
||||
|
||||
client = CopilotClient(settings=settings, token=token)
|
||||
|
||||
super().__init__(
|
||||
client=client, # type: ignore[reportCallIssue]
|
||||
settings=settings, # type: ignore[reportCallIssue]
|
||||
token=token, # type: ignore[reportCallIssue]
|
||||
cloud=cloud, # type: ignore[reportCallIssue]
|
||||
agent_type=agent_type, # type: ignore[reportCallIssue]
|
||||
custom_power_platform_cloud=custom_power_platform_cloud, # type: ignore[reportCallIssue]
|
||||
username=username, # type: ignore[reportCallIssue]
|
||||
token_cache=token_cache, # type: ignore[reportCallIssue]
|
||||
scopes=scopes, # type: ignore[reportCallIssue]
|
||||
)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentRunResponse:
|
||||
"""Get a response from the agent.
|
||||
|
||||
This method returns the final result of the agent's execution
|
||||
as a single AgentRunResponse object. The caller is blocked until
|
||||
the final result is available.
|
||||
|
||||
Note: For streaming responses, use the run_stream method, which returns
|
||||
intermediate steps and the final result as a stream of AgentRunResponseUpdate
|
||||
objects. Streaming only the final result is not feasible because the timing of
|
||||
the final result's availability is unknown, and blocking the caller until then
|
||||
is undesirable in streaming scenarios.
|
||||
|
||||
Args:
|
||||
messages: The message(s) to send to the agent.
|
||||
thread: The conversation thread associated with the message(s).
|
||||
kwargs: Additional keyword arguments.
|
||||
|
||||
Returns:
|
||||
An agent response item.
|
||||
"""
|
||||
if not thread:
|
||||
thread = self.get_new_thread()
|
||||
thread.service_thread_id = await self._start_new_conversation()
|
||||
|
||||
input_messages = self._normalize_messages(messages)
|
||||
|
||||
question = "\n".join([message.text for message in input_messages])
|
||||
|
||||
activities = self.client.ask_question(question, thread.service_thread_id)
|
||||
response_messages: list[ChatMessage] = []
|
||||
response_id: str | None = None
|
||||
|
||||
response_messages = [message async for message in self._process_activities(activities, streaming=False)]
|
||||
response_id = response_messages[0].message_id if response_messages else None
|
||||
|
||||
return AgentRunResponse(messages=response_messages, response_id=response_id)
|
||||
|
||||
async def run_stream(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterable[AgentRunResponseUpdate]:
|
||||
"""Run the agent as a stream.
|
||||
|
||||
This method will return the intermediate steps and final results of the
|
||||
agent's execution as a stream of AgentRunResponseUpdate objects to the caller.
|
||||
|
||||
Note: An AgentRunResponseUpdate object contains a chunk of a message.
|
||||
|
||||
Args:
|
||||
messages: The message(s) to send to the agent.
|
||||
thread: The conversation thread associated with the message(s).
|
||||
kwargs: Additional keyword arguments.
|
||||
|
||||
Yields:
|
||||
An agent response item.
|
||||
"""
|
||||
if not thread:
|
||||
thread = self.get_new_thread()
|
||||
thread.service_thread_id = await self._start_new_conversation()
|
||||
|
||||
input_messages = self._normalize_messages(messages)
|
||||
|
||||
question = "\n".join([message.text for message in input_messages])
|
||||
|
||||
activities = self.client.ask_question(question, thread.service_thread_id)
|
||||
|
||||
async for message in self._process_activities(activities, streaming=True):
|
||||
yield AgentRunResponseUpdate(
|
||||
role=message.role,
|
||||
contents=message.contents,
|
||||
additional_properties=message.additional_properties,
|
||||
author_name=message.author_name,
|
||||
raw_representation=message.raw_representation,
|
||||
response_id=message.message_id,
|
||||
message_id=message.message_id,
|
||||
)
|
||||
|
||||
async def _start_new_conversation(self) -> str:
|
||||
"""Start a new conversation with the Copilot Studio agent.
|
||||
|
||||
Returns:
|
||||
The conversation ID for the new conversation.
|
||||
|
||||
Raises:
|
||||
ServiceException: If the conversation could not be started.
|
||||
"""
|
||||
conversation_id: str | None = None
|
||||
|
||||
async for activity in self.client.start_conversation(emit_start_conversation_event=True):
|
||||
if activity and activity.conversation and activity.conversation.id:
|
||||
conversation_id = activity.conversation.id
|
||||
|
||||
if not conversation_id:
|
||||
raise ServiceException("Failed to start a new conversation.")
|
||||
|
||||
return conversation_id
|
||||
|
||||
async def _process_activities(self, activities: AsyncIterable[Any], streaming: bool) -> AsyncIterable[ChatMessage]:
|
||||
"""Process activities from the Copilot Studio agent.
|
||||
|
||||
Args:
|
||||
activities: Stream of activities from the agent.
|
||||
streaming: Whether to process activities for streaming (typing activities)
|
||||
or non-streaming (message activities) responses.
|
||||
|
||||
Yields:
|
||||
ChatMessage objects created from the activities.
|
||||
"""
|
||||
async for activity in activities:
|
||||
if activity.text and (
|
||||
(activity.type == "message" and not streaming) or (activity.type == "typing" and streaming)
|
||||
):
|
||||
yield ChatMessage(
|
||||
role=Role.ASSISTANT,
|
||||
contents=[TextContent(activity.text)],
|
||||
author_name=activity.from_property.name if activity.from_property else None,
|
||||
message_id=activity.id,
|
||||
raw_representation=activity,
|
||||
)
|
||||
@@ -0,0 +1,93 @@
|
||||
[project]
|
||||
name = "agent-framework-copilotstudio"
|
||||
description = "Copilot Studio integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "SK-Support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "0.1.0b1"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://learn.microsoft.com/en-us/semantic-kernel/overview/"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
|
||||
urls.issues = "https://github.com/microsoft/agent-framework/issues"
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Framework :: Pydantic :: 2",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework",
|
||||
"microsoft-agents-copilotstudio-client>=0.3.1",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
prerelease = "if-necessary-or-explicit"
|
||||
environments = [
|
||||
"sys_platform == 'darwin'",
|
||||
"sys_platform == 'linux'",
|
||||
"sys_platform == 'win32'"
|
||||
]
|
||||
|
||||
[tool.uv-dynamic-versioning]
|
||||
fallback-version = "0.0.0"
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = 'tests'
|
||||
addopts = "-ra -q -r fEX"
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
filterwarnings = [
|
||||
"ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*"
|
||||
]
|
||||
timeout = 120
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
|
||||
[tool.coverage.run]
|
||||
omit = [
|
||||
"**/__init__.py"
|
||||
]
|
||||
|
||||
[tool.pyright]
|
||||
extend = "../../pyproject.toml"
|
||||
exclude = ['tests']
|
||||
|
||||
[tool.mypy]
|
||||
plugins = ['pydantic.mypy']
|
||||
strict = true
|
||||
python_version = "3.10"
|
||||
ignore_missing_imports = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
check_untyped_defs = true
|
||||
warn_return_any = true
|
||||
show_error_codes = true
|
||||
warn_unused_ignores = false
|
||||
disallow_incomplete_defs = true
|
||||
disallow_untyped_decorators = true
|
||||
|
||||
[tool.bandit]
|
||||
targets = ["agent_framework_copilotstudio"]
|
||||
exclude_dirs = ["tests"]
|
||||
|
||||
[tool.poe]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_copilotstudio"
|
||||
test = "pytest --cov=agent_framework_copilotstudio --cov-report=term-missing:skip-covered tests"
|
||||
|
||||
[tool.uv.build-backend]
|
||||
module-name = "agent_framework_copilotstudio"
|
||||
module-root = ""
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.8.2,<0.9.0"]
|
||||
build-backend = "uv_build"
|
||||
@@ -0,0 +1,91 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from microsoft_agents.copilotstudio.client import CopilotClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def exclude_list(request: Any) -> list[str]:
|
||||
"""Fixture that returns a list of environment variables to exclude."""
|
||||
return request.param if hasattr(request, "param") else []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def override_env_param_dict(request: Any) -> dict[str, str]:
|
||||
"""Fixture that returns a dict of environment variables to override."""
|
||||
return request.param if hasattr(request, "param") else {}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def copilot_studio_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # type: ignore
|
||||
"""Fixture to set environment variables for CopilotStudioSettings."""
|
||||
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {
|
||||
"COPILOTSTUDIOAGENT__ENVIRONMENTID": "test-environment-id",
|
||||
"COPILOTSTUDIOAGENT__SCHEMANAME": "test-schema-name",
|
||||
"COPILOTSTUDIOAGENT__AGENTAPPID": "test-client-id",
|
||||
"COPILOTSTUDIOAGENT__TENANTID": "test-tenant-id",
|
||||
}
|
||||
|
||||
env_vars.update(override_env_param_dict) # type: ignore
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key in exclude_list:
|
||||
monkeypatch.delenv(key, raising=False) # type: ignore
|
||||
continue
|
||||
monkeypatch.setenv(key, value) # type: ignore
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_copilot_client() -> MagicMock:
|
||||
"""Mock CopilotClient for testing."""
|
||||
return MagicMock(spec=CopilotClient)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pca() -> MagicMock:
|
||||
"""Mock PublicClientApplication for testing."""
|
||||
mock_pca = MagicMock()
|
||||
|
||||
# Mock successful token response
|
||||
mock_token_response = {
|
||||
"access_token": "test-access-token-12345",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
}
|
||||
|
||||
mock_pca.get_accounts.return_value = []
|
||||
mock_pca.acquire_token_interactive.return_value = mock_token_response
|
||||
mock_pca.acquire_token_silent.return_value = mock_token_response
|
||||
|
||||
return mock_pca
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_activity() -> MagicMock:
|
||||
"""Mock Activity for testing."""
|
||||
mock_activity = MagicMock()
|
||||
mock_activity.text = "Test response"
|
||||
mock_activity.type = "message"
|
||||
mock_activity.id = "test-activity-id"
|
||||
mock_activity.from_property.name = "Test Bot"
|
||||
return mock_activity
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_conversation() -> MagicMock:
|
||||
"""Mock conversation for testing."""
|
||||
mock_conversation = MagicMock()
|
||||
mock_conversation.id = "test-conversation-id"
|
||||
return mock_conversation
|
||||
@@ -0,0 +1,243 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework.exceptions import ServiceException
|
||||
|
||||
from agent_framework_copilotstudio._acquire_token import DEFAULT_SCOPES, acquire_token
|
||||
|
||||
|
||||
class TestAcquireToken:
|
||||
"""Test class for token acquisition functionality."""
|
||||
|
||||
def test_acquire_token_missing_client_id(self) -> None:
|
||||
"""Test that acquire_token raises ServiceException when client_id is missing."""
|
||||
with pytest.raises(ServiceException, match="Client ID is required for token acquisition"):
|
||||
acquire_token(client_id="", tenant_id="test-tenant-id")
|
||||
|
||||
def test_acquire_token_missing_tenant_id(self) -> None:
|
||||
"""Test that acquire_token raises ServiceException when tenant_id is missing."""
|
||||
with pytest.raises(ServiceException, match="Tenant ID is required for token acquisition"):
|
||||
acquire_token(client_id="test-client-id", tenant_id="")
|
||||
|
||||
def test_acquire_token_none_client_id(self) -> None:
|
||||
"""Test that acquire_token raises ServiceException when client_id is None."""
|
||||
with pytest.raises(ServiceException, match="Client ID is required for token acquisition"):
|
||||
acquire_token(client_id=None, tenant_id="test-tenant-id") # type: ignore
|
||||
|
||||
def test_acquire_token_none_tenant_id(self) -> None:
|
||||
"""Test that acquire_token raises ServiceException when tenant_id is None."""
|
||||
with pytest.raises(ServiceException, match="Tenant ID is required for token acquisition"):
|
||||
acquire_token(client_id="test-client-id", tenant_id=None) # type: ignore
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.PublicClientApplication")
|
||||
def test_acquire_token_silent_success(self, mock_pca_class: MagicMock) -> None:
|
||||
"""Test successful silent token acquisition."""
|
||||
mock_pca = MagicMock()
|
||||
mock_pca_class.return_value = mock_pca
|
||||
|
||||
mock_account = MagicMock()
|
||||
mock_pca.get_accounts.return_value = [mock_account]
|
||||
|
||||
mock_token_response = {"access_token": "test-access-token-12345"}
|
||||
mock_pca.acquire_token_silent.return_value = mock_token_response
|
||||
|
||||
result = acquire_token(
|
||||
client_id="test-client-id",
|
||||
tenant_id="test-tenant-id",
|
||||
)
|
||||
|
||||
assert result == "test-access-token-12345"
|
||||
mock_pca_class.assert_called_once_with(
|
||||
client_id="test-client-id",
|
||||
authority="https://login.microsoftonline.com/test-tenant-id",
|
||||
token_cache=None,
|
||||
)
|
||||
mock_pca.get_accounts.assert_called_once_with(username=None)
|
||||
mock_pca.acquire_token_silent.assert_called_once_with(scopes=DEFAULT_SCOPES, account=mock_account)
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.PublicClientApplication")
|
||||
def test_acquire_token_silent_success_with_username(self, mock_pca_class: MagicMock) -> None:
|
||||
"""Test successful silent token acquisition with username."""
|
||||
mock_pca = MagicMock()
|
||||
mock_pca_class.return_value = mock_pca
|
||||
|
||||
mock_account = MagicMock()
|
||||
mock_pca.get_accounts.return_value = [mock_account]
|
||||
|
||||
mock_token_response = {"access_token": "test-access-token-12345"}
|
||||
mock_pca.acquire_token_silent.return_value = mock_token_response
|
||||
|
||||
result = acquire_token(
|
||||
client_id="test-client-id",
|
||||
tenant_id="test-tenant-id",
|
||||
username="test-user@example.com",
|
||||
)
|
||||
|
||||
assert result == "test-access-token-12345"
|
||||
mock_pca.get_accounts.assert_called_once_with(username="test-user@example.com")
|
||||
mock_pca.acquire_token_silent.assert_called_once_with(scopes=DEFAULT_SCOPES, account=mock_account)
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.PublicClientApplication")
|
||||
def test_acquire_token_silent_success_with_custom_scopes(self, mock_pca_class: MagicMock) -> None:
|
||||
"""Test successful silent token acquisition with custom scopes."""
|
||||
# Setup
|
||||
mock_pca = MagicMock()
|
||||
mock_pca_class.return_value = mock_pca
|
||||
|
||||
mock_account = MagicMock()
|
||||
mock_pca.get_accounts.return_value = [mock_account]
|
||||
|
||||
mock_token_response = {"access_token": "test-access-token-12345"}
|
||||
mock_pca.acquire_token_silent.return_value = mock_token_response
|
||||
|
||||
custom_scopes = ["https://custom.api.com/.default"]
|
||||
|
||||
result = acquire_token(
|
||||
client_id="test-client-id",
|
||||
tenant_id="test-tenant-id",
|
||||
scopes=custom_scopes,
|
||||
)
|
||||
|
||||
assert result == "test-access-token-12345"
|
||||
mock_pca.acquire_token_silent.assert_called_once_with(scopes=custom_scopes, account=mock_account)
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.PublicClientApplication")
|
||||
def test_acquire_token_interactive_success_no_accounts(self, mock_pca_class: MagicMock) -> None:
|
||||
"""Test successful interactive token acquisition when no cached accounts exist."""
|
||||
# Setup
|
||||
mock_pca = MagicMock()
|
||||
mock_pca_class.return_value = mock_pca
|
||||
|
||||
mock_pca.get_accounts.return_value = [] # No cached accounts
|
||||
|
||||
mock_token_response = {"access_token": "test-interactive-token-67890"}
|
||||
mock_pca.acquire_token_interactive.return_value = mock_token_response
|
||||
|
||||
result = acquire_token(
|
||||
client_id="test-client-id",
|
||||
tenant_id="test-tenant-id",
|
||||
)
|
||||
|
||||
assert result == "test-interactive-token-67890"
|
||||
mock_pca.acquire_token_interactive.assert_called_once_with(scopes=DEFAULT_SCOPES)
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.PublicClientApplication")
|
||||
def test_acquire_token_fallback_to_interactive_after_silent_fails(self, mock_pca_class: MagicMock) -> None:
|
||||
"""Test fallback to interactive authentication when silent acquisition fails."""
|
||||
mock_pca = MagicMock()
|
||||
mock_pca_class.return_value = mock_pca
|
||||
|
||||
mock_account = MagicMock()
|
||||
mock_pca.get_accounts.return_value = [mock_account]
|
||||
|
||||
# Silent acquisition fails with error response
|
||||
mock_silent_error_response = {"error": "invalid_grant", "error_description": "Token expired"}
|
||||
mock_pca.acquire_token_silent.return_value = mock_silent_error_response
|
||||
|
||||
# Interactive acquisition succeeds
|
||||
mock_interactive_response = {"access_token": "test-interactive-token-67890"}
|
||||
mock_pca.acquire_token_interactive.return_value = mock_interactive_response
|
||||
|
||||
result = acquire_token(
|
||||
client_id="test-client-id",
|
||||
tenant_id="test-tenant-id",
|
||||
)
|
||||
|
||||
assert result == "test-interactive-token-67890"
|
||||
mock_pca.acquire_token_silent.assert_called_once_with(scopes=DEFAULT_SCOPES, account=mock_account)
|
||||
mock_pca.acquire_token_interactive.assert_called_once_with(scopes=DEFAULT_SCOPES)
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.PublicClientApplication")
|
||||
def test_acquire_token_fallback_to_interactive_after_silent_exception(self, mock_pca_class: MagicMock) -> None:
|
||||
"""Test fallback to interactive authentication when silent acquisition throws exception."""
|
||||
mock_pca = MagicMock()
|
||||
mock_pca_class.return_value = mock_pca
|
||||
|
||||
mock_account = MagicMock()
|
||||
mock_pca.get_accounts.return_value = [mock_account]
|
||||
|
||||
# Silent acquisition throws exception
|
||||
mock_pca.acquire_token_silent.side_effect = Exception("Network error")
|
||||
|
||||
# Interactive acquisition succeeds
|
||||
mock_interactive_response = {"access_token": "test-interactive-token-67890"}
|
||||
mock_pca.acquire_token_interactive.return_value = mock_interactive_response
|
||||
|
||||
result = acquire_token(
|
||||
client_id="test-client-id",
|
||||
tenant_id="test-tenant-id",
|
||||
)
|
||||
|
||||
assert result == "test-interactive-token-67890"
|
||||
mock_pca.acquire_token_silent.assert_called_once_with(scopes=DEFAULT_SCOPES, account=mock_account)
|
||||
mock_pca.acquire_token_interactive.assert_called_once_with(scopes=DEFAULT_SCOPES)
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.PublicClientApplication")
|
||||
def test_acquire_token_interactive_error_response(self, mock_pca_class: MagicMock) -> None:
|
||||
"""Test that acquire_token handles error responses from interactive authentication."""
|
||||
mock_pca = MagicMock()
|
||||
mock_pca_class.return_value = mock_pca
|
||||
|
||||
mock_pca.get_accounts.return_value = [] # No cached accounts
|
||||
|
||||
# Interactive acquisition returns error
|
||||
mock_error_response = {"error": "access_denied", "error_description": "User denied consent"}
|
||||
mock_pca.acquire_token_interactive.return_value = mock_error_response
|
||||
|
||||
with pytest.raises(ServiceException, match="Authentication token cannot be acquired"):
|
||||
acquire_token(
|
||||
client_id="test-client-id",
|
||||
tenant_id="test-tenant-id",
|
||||
)
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.PublicClientApplication")
|
||||
def test_acquire_token_interactive_exception(self, mock_pca_class: MagicMock) -> None:
|
||||
"""Test that acquire_token handles exceptions from interactive authentication."""
|
||||
mock_pca = MagicMock()
|
||||
mock_pca_class.return_value = mock_pca
|
||||
|
||||
mock_pca.get_accounts.return_value = [] # No cached accounts
|
||||
|
||||
# Interactive acquisition throws exception
|
||||
mock_pca.acquire_token_interactive.side_effect = Exception("Authentication service unavailable")
|
||||
|
||||
with pytest.raises(ServiceException, match="Failed to acquire authentication token"):
|
||||
acquire_token(
|
||||
client_id="test-client-id",
|
||||
tenant_id="test-tenant-id",
|
||||
)
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.PublicClientApplication")
|
||||
def test_acquire_token_with_token_cache(self, mock_pca_class: MagicMock) -> None:
|
||||
"""Test acquire_token with custom token cache."""
|
||||
mock_pca = MagicMock()
|
||||
mock_pca_class.return_value = mock_pca
|
||||
|
||||
mock_account = MagicMock()
|
||||
mock_pca.get_accounts.return_value = [mock_account]
|
||||
|
||||
mock_token_response = {"access_token": "test-cached-token"}
|
||||
mock_pca.acquire_token_silent.return_value = mock_token_response
|
||||
|
||||
mock_token_cache = MagicMock()
|
||||
|
||||
result = acquire_token(
|
||||
client_id="test-client-id",
|
||||
tenant_id="test-tenant-id",
|
||||
token_cache=mock_token_cache,
|
||||
)
|
||||
|
||||
assert result == "test-cached-token"
|
||||
mock_pca_class.assert_called_once_with(
|
||||
client_id="test-client-id",
|
||||
authority="https://login.microsoftonline.com/test-tenant-id",
|
||||
token_cache=mock_token_cache,
|
||||
)
|
||||
|
||||
def test_default_scopes_constant(self) -> None:
|
||||
"""Test that DEFAULT_SCOPES constant is properly defined."""
|
||||
assert DEFAULT_SCOPES == ["https://api.powerplatform.com/.default"]
|
||||
assert isinstance(DEFAULT_SCOPES, list)
|
||||
assert len(DEFAULT_SCOPES) == 1
|
||||
@@ -0,0 +1,325 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import (
|
||||
AgentRunResponse,
|
||||
AgentRunResponseUpdate,
|
||||
AgentThread,
|
||||
ChatMessage,
|
||||
Role,
|
||||
TextContent,
|
||||
)
|
||||
from agent_framework.exceptions import ServiceException, ServiceInitializationError
|
||||
from microsoft_agents.copilotstudio.client import CopilotClient
|
||||
|
||||
from agent_framework_copilotstudio import CopilotStudioAgent
|
||||
|
||||
|
||||
def create_async_generator(items: list[Any]) -> Any:
|
||||
"""Helper to create async generator mock."""
|
||||
|
||||
async def async_gen() -> Any:
|
||||
for item in items:
|
||||
yield item
|
||||
|
||||
return async_gen()
|
||||
|
||||
|
||||
class TestCopilotStudioAgent:
|
||||
"""Test cases for CopilotStudioAgent."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_activity(self) -> MagicMock:
|
||||
activity = MagicMock()
|
||||
activity.text = "Test response"
|
||||
activity.type = "message"
|
||||
activity.id = "test-id"
|
||||
activity.from_property.name = "Test Bot"
|
||||
return activity
|
||||
|
||||
@pytest.fixture
|
||||
def mock_copilot_client(self) -> MagicMock:
|
||||
return MagicMock(spec=CopilotClient)
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.acquire_token")
|
||||
@patch("agent_framework_copilotstudio._agent.CopilotStudioSettings")
|
||||
def test_init_missing_environment_id(self, mock_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
|
||||
mock_acquire_token.return_value = "fake-token"
|
||||
mock_settings.return_value.environmentid = None
|
||||
mock_settings.return_value.schemaname = "test-bot"
|
||||
mock_settings.return_value.tenantid = "test-tenant"
|
||||
mock_settings.return_value.agentappid = "test-client"
|
||||
|
||||
with pytest.raises(ServiceInitializationError, match="environment ID is required"):
|
||||
CopilotStudioAgent()
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.acquire_token")
|
||||
@patch("agent_framework_copilotstudio._agent.CopilotStudioSettings")
|
||||
def test_init_missing_bot_id(self, mock_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
|
||||
mock_acquire_token.return_value = "fake-token"
|
||||
mock_settings.return_value.environmentid = "test-env"
|
||||
mock_settings.return_value.schemaname = None
|
||||
mock_settings.return_value.tenantid = "test-tenant"
|
||||
mock_settings.return_value.agentappid = "test-client"
|
||||
|
||||
with pytest.raises(ServiceInitializationError, match="agent identifier"):
|
||||
CopilotStudioAgent()
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.acquire_token")
|
||||
@patch("agent_framework_copilotstudio._agent.CopilotStudioSettings")
|
||||
def test_init_missing_tenant_id(self, mock_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
|
||||
mock_acquire_token.return_value = "fake-token"
|
||||
mock_settings.return_value.environmentid = "test-env"
|
||||
mock_settings.return_value.schemaname = "test-bot"
|
||||
mock_settings.return_value.tenantid = None
|
||||
mock_settings.return_value.agentappid = "test-client"
|
||||
|
||||
with pytest.raises(ServiceInitializationError, match="tenant ID is required"):
|
||||
CopilotStudioAgent()
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.acquire_token")
|
||||
@patch("agent_framework_copilotstudio._agent.CopilotStudioSettings")
|
||||
def test_init_missing_client_id(self, mock_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
|
||||
mock_acquire_token.return_value = "fake-token"
|
||||
mock_settings.return_value.environmentid = "test-env"
|
||||
mock_settings.return_value.schemaname = "test-bot"
|
||||
mock_settings.return_value.tenantid = "test-tenant"
|
||||
mock_settings.return_value.agentappid = None
|
||||
|
||||
with pytest.raises(ServiceInitializationError, match="client ID is required"):
|
||||
CopilotStudioAgent()
|
||||
|
||||
def test_init_with_client(self, mock_copilot_client: MagicMock) -> None:
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
assert agent.client == mock_copilot_client
|
||||
assert agent.id is not None
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.acquire_token")
|
||||
def test_init_empty_environment_id(self, mock_acquire_token: MagicMock) -> None:
|
||||
mock_acquire_token.return_value = "fake-token"
|
||||
with patch("agent_framework_copilotstudio._agent.CopilotStudioSettings") as mock_settings:
|
||||
mock_settings.return_value.environmentid = ""
|
||||
mock_settings.return_value.schemaname = "test-bot"
|
||||
mock_settings.return_value.tenantid = "test-tenant"
|
||||
mock_settings.return_value.agentappid = "test-client"
|
||||
|
||||
with pytest.raises(ServiceInitializationError, match="environment ID is required"):
|
||||
CopilotStudioAgent()
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.acquire_token")
|
||||
def test_init_empty_schema_name(self, mock_acquire_token: MagicMock) -> None:
|
||||
mock_acquire_token.return_value = "fake-token"
|
||||
with patch("agent_framework_copilotstudio._agent.CopilotStudioSettings") as mock_settings:
|
||||
mock_settings.return_value.environmentid = "test-env"
|
||||
mock_settings.return_value.schemaname = ""
|
||||
mock_settings.return_value.tenantid = "test-tenant"
|
||||
mock_settings.return_value.agentappid = "test-client"
|
||||
|
||||
with pytest.raises(ServiceInitializationError, match="agent identifier"):
|
||||
CopilotStudioAgent()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_with_string_message(self, mock_copilot_client: MagicMock, mock_activity: MagicMock) -> None:
|
||||
"""Test run method with string message."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
|
||||
conversation_activity = MagicMock()
|
||||
conversation_activity.conversation.id = "test-conversation-id"
|
||||
|
||||
mock_copilot_client.start_conversation.return_value = create_async_generator([conversation_activity])
|
||||
mock_copilot_client.ask_question.return_value = create_async_generator([mock_activity])
|
||||
|
||||
response = await agent.run("test message")
|
||||
|
||||
assert isinstance(response, AgentRunResponse)
|
||||
assert len(response.messages) == 1
|
||||
content = response.messages[0].contents[0]
|
||||
assert isinstance(content, TextContent)
|
||||
assert content.text == "Test response"
|
||||
assert response.messages[0].role == Role.ASSISTANT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_with_chat_message(self, mock_copilot_client: MagicMock, mock_activity: MagicMock) -> None:
|
||||
"""Test run method with ChatMessage."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
|
||||
conversation_activity = MagicMock()
|
||||
conversation_activity.conversation.id = "test-conversation-id"
|
||||
|
||||
mock_copilot_client.start_conversation.return_value = create_async_generator([conversation_activity])
|
||||
mock_copilot_client.ask_question.return_value = create_async_generator([mock_activity])
|
||||
|
||||
chat_message = ChatMessage(role=Role.USER, contents=[TextContent("test message")])
|
||||
response = await agent.run(chat_message)
|
||||
|
||||
assert isinstance(response, AgentRunResponse)
|
||||
assert len(response.messages) == 1
|
||||
content = response.messages[0].contents[0]
|
||||
assert isinstance(content, TextContent)
|
||||
assert content.text == "Test response"
|
||||
assert response.messages[0].role == Role.ASSISTANT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_with_thread(self, mock_copilot_client: MagicMock, mock_activity: MagicMock) -> None:
|
||||
"""Test run method with existing thread."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
thread = AgentThread()
|
||||
|
||||
conversation_activity = MagicMock()
|
||||
conversation_activity.conversation.id = "test-conversation-id"
|
||||
|
||||
mock_copilot_client.start_conversation.return_value = create_async_generator([conversation_activity])
|
||||
mock_copilot_client.ask_question.return_value = create_async_generator([mock_activity])
|
||||
|
||||
response = await agent.run("test message", thread=thread)
|
||||
|
||||
assert isinstance(response, AgentRunResponse)
|
||||
assert len(response.messages) == 1
|
||||
assert thread.service_thread_id == "test-conversation-id"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_start_conversation_failure(self, mock_copilot_client: MagicMock) -> None:
|
||||
"""Test run method when conversation start fails."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
|
||||
mock_copilot_client.start_conversation.return_value = create_async_generator([])
|
||||
|
||||
with pytest.raises(ServiceException, match="Failed to start a new conversation"):
|
||||
await agent.run("test message")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_stream_with_string_message(self, mock_copilot_client: MagicMock) -> None:
|
||||
"""Test run_stream method with string message."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
|
||||
conversation_activity = MagicMock()
|
||||
conversation_activity.conversation.id = "test-conversation-id"
|
||||
|
||||
typing_activity = MagicMock()
|
||||
typing_activity.text = "Streaming response"
|
||||
typing_activity.type = "typing"
|
||||
typing_activity.id = "test-typing-id"
|
||||
typing_activity.from_property.name = "Test Bot"
|
||||
|
||||
mock_copilot_client.start_conversation.return_value = create_async_generator([conversation_activity])
|
||||
mock_copilot_client.ask_question.return_value = create_async_generator([typing_activity])
|
||||
|
||||
response_count = 0
|
||||
async for response in agent.run_stream("test message"):
|
||||
assert isinstance(response, AgentRunResponseUpdate)
|
||||
content = response.contents[0]
|
||||
assert isinstance(content, TextContent)
|
||||
assert content.text == "Streaming response"
|
||||
response_count += 1
|
||||
|
||||
assert response_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_stream_with_thread(self, mock_copilot_client: MagicMock) -> None:
|
||||
"""Test run_stream method with existing thread."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
thread = AgentThread()
|
||||
|
||||
conversation_activity = MagicMock()
|
||||
conversation_activity.conversation.id = "test-conversation-id"
|
||||
|
||||
typing_activity = MagicMock()
|
||||
typing_activity.text = "Streaming response"
|
||||
typing_activity.type = "typing"
|
||||
typing_activity.id = "test-typing-id"
|
||||
typing_activity.from_property.name = "Test Bot"
|
||||
|
||||
mock_copilot_client.start_conversation.return_value = create_async_generator([conversation_activity])
|
||||
mock_copilot_client.ask_question.return_value = create_async_generator([typing_activity])
|
||||
|
||||
response_count = 0
|
||||
async for response in agent.run_stream("test message", thread=thread):
|
||||
assert isinstance(response, AgentRunResponseUpdate)
|
||||
content = response.contents[0]
|
||||
assert isinstance(content, TextContent)
|
||||
assert content.text == "Streaming response"
|
||||
response_count += 1
|
||||
|
||||
assert response_count == 1
|
||||
assert thread.service_thread_id == "test-conversation-id"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_stream_no_typing_activity(self, mock_copilot_client: MagicMock) -> None:
|
||||
"""Test run_stream method with non-typing activity."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
|
||||
conversation_activity = MagicMock()
|
||||
conversation_activity.conversation.id = "test-conversation-id"
|
||||
|
||||
message_activity = MagicMock()
|
||||
message_activity.text = "Message response"
|
||||
message_activity.type = "message"
|
||||
message_activity.id = "test-message-id"
|
||||
|
||||
mock_copilot_client.start_conversation.return_value = create_async_generator([conversation_activity])
|
||||
mock_copilot_client.ask_question.return_value = create_async_generator([message_activity])
|
||||
|
||||
response_count = 0
|
||||
async for _response in agent.run_stream("test message"):
|
||||
response_count += 1
|
||||
|
||||
assert response_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_multiple_activities(self, mock_copilot_client: MagicMock) -> None:
|
||||
"""Test run method with multiple message activities."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
|
||||
conversation_activity = MagicMock()
|
||||
conversation_activity.conversation.id = "test-conversation-id"
|
||||
|
||||
activity1 = MagicMock()
|
||||
activity1.text = "First response"
|
||||
activity1.type = "message"
|
||||
activity1.id = "test-id-1"
|
||||
activity1.from_property.name = "Test Bot"
|
||||
|
||||
activity2 = MagicMock()
|
||||
activity2.text = "Second response"
|
||||
activity2.type = "message"
|
||||
activity2.id = "test-id-2"
|
||||
activity2.from_property.name = "Test Bot"
|
||||
|
||||
mock_copilot_client.start_conversation.return_value = create_async_generator([conversation_activity])
|
||||
mock_copilot_client.ask_question.return_value = create_async_generator([activity1, activity2])
|
||||
|
||||
response = await agent.run("test message")
|
||||
|
||||
assert isinstance(response, AgentRunResponse)
|
||||
assert len(response.messages) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_list_of_messages(self, mock_copilot_client: MagicMock, mock_activity: MagicMock) -> None:
|
||||
"""Test run method with list of messages."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
|
||||
conversation_activity = MagicMock()
|
||||
conversation_activity.conversation.id = "test-conversation-id"
|
||||
|
||||
mock_copilot_client.start_conversation.return_value = create_async_generator([conversation_activity])
|
||||
mock_copilot_client.ask_question.return_value = create_async_generator([mock_activity])
|
||||
|
||||
messages = ["Hello", "How are you?"]
|
||||
response = await agent.run(messages)
|
||||
|
||||
assert isinstance(response, AgentRunResponse)
|
||||
assert len(response.messages) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_stream_start_conversation_failure(self, mock_copilot_client: MagicMock) -> None:
|
||||
"""Test run_stream method when conversation start fails."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
|
||||
mock_copilot_client.start_conversation.return_value = create_async_generator([])
|
||||
|
||||
with pytest.raises(ServiceException, match="Failed to start a new conversation"):
|
||||
async for _ in agent.run_stream("test message"):
|
||||
pass
|
||||
@@ -0,0 +1,28 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
|
||||
def test_self_through_main() -> None:
|
||||
try:
|
||||
from agent_framework.copilotstudio import __version__
|
||||
except ImportError:
|
||||
__version__ = None
|
||||
|
||||
assert __version__ is not None
|
||||
|
||||
|
||||
def test_self() -> None:
|
||||
try:
|
||||
from agent_framework_copilotstudio import __version__
|
||||
except ImportError:
|
||||
__version__ = None
|
||||
|
||||
assert __version__ is not None
|
||||
|
||||
|
||||
def test_agent_framework() -> None:
|
||||
try:
|
||||
from agent_framework import __version__
|
||||
except ImportError:
|
||||
__version__ = None
|
||||
|
||||
assert __version__ is not None
|
||||
Reference in New Issue
Block a user