chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
# Copilot Studio Package (agent-framework-copilotstudio)
Integration with Microsoft Copilot Studio agents.
## Main Classes
- **`CopilotStudioAgent`** - Agent that connects to a Copilot Studio bot
- **`acquire_token`** - Helper function for authentication
## Usage
```python
from agent_framework.microsoft import CopilotStudioAgent
agent = CopilotStudioAgent(
bot_identifier="your-bot-id",
environment_id="your-env-id",
)
response = await agent.run("Hello")
```
## Import Path
```python
from agent_framework.microsoft import CopilotStudioAgent
# or directly:
from agent_framework_copilotstudio import CopilotStudioAgent
```
+21
View File
@@ -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
+97
View File
@@ -0,0 +1,97 @@
# Get Started with Microsoft Agent Framework Copilot Studio
Please install this package via pip:
```bash
pip install agent-framework-copilotstudio --pre
```
## 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.microsoft 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.microsoft 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,
custom_power_platform_cloud=None
)
# 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](../../samples/02-agents/providers/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,95 @@
# 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 AgentException
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.
Keyword 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:
AgentException: If authentication token cannot be acquired.
"""
if not client_id:
raise ValueError("Client ID is required for token acquisition.")
if not tenant_id:
raise ValueError("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"])
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"])
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 AgentException(f"Failed to acquire authentication token: {ex}") from ex
if not token:
raise AgentException("Authentication token cannot be acquired.")
return token
@@ -0,0 +1,348 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
from collections.abc import AsyncIterable, Awaitable, Sequence
from typing import Any, Literal, TypedDict, overload
from agent_framework import (
AgentMiddlewareTypes,
AgentResponse,
AgentResponseUpdate,
AgentSession,
BaseAgent,
Content,
ContextProvider,
Message,
ResponseStream,
normalize_messages,
)
from agent_framework._settings import load_settings
from agent_framework._types import AgentRunInputs
from agent_framework.exceptions import AgentException
from microsoft_agents.copilotstudio.client import AgentType, ConnectionSettings, CopilotClient, PowerPlatformCloud
from ._acquire_token import acquire_token
class CopilotStudioSettings(TypedDict, total=False):
"""Copilot Studio model settings.
Settings are resolved in this order: explicit keyword arguments, values from an
explicitly provided .env file, then environment variables with the prefix
'COPILOTSTUDIOAGENT__'.
Keys:
environmentid: Environment ID of environment with the Copilot Studio App.
Can be set via environment variable COPILOTSTUDIOAGENT__ENVIRONMENTID.
schemaname: The agent identifier or schema name of the Copilot to use.
Can be set via environment variable COPILOTSTUDIOAGENT__SCHEMANAME.
agentappid: The app ID of the App Registration used to login.
Can be set via environment variable COPILOTSTUDIOAGENT__AGENTAPPID.
tenantid: The tenant ID of the App Registration used to login.
Can be set via environment variable COPILOTSTUDIOAGENT__TENANTID.
"""
environmentid: str | None
schemaname: str | None
agentappid: str | None
tenantid: str | None
class CopilotStudioAgent(BaseAgent):
"""A Copilot Studio Agent."""
def __init__(
self,
client: CopilotClient | None = None,
settings: ConnectionSettings | None = None,
*,
id: str | None = None,
name: str | None = None,
description: str | None = None,
context_providers: Sequence[ContextProvider] | None = None,
middleware: list[AgentMiddlewareTypes] | 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.
Keyword Args:
id: id of the CopilotAgent
name: Name of the CopilotAgent
description: Description of the CopilotAgent
context_providers: Context Providers, to be used by the copilot agent.
middleware: Agent middleware used by the agent, should be a list of AgentMiddlewareTypes.
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:
ValueError: If required configuration is missing or invalid.
"""
super().__init__(
id=id,
name=name,
description=description,
context_providers=context_providers,
middleware=middleware,
)
if not client:
copilot_studio_settings = load_settings(
CopilotStudioSettings,
env_prefix="COPILOTSTUDIOAGENT__",
environmentid=environment_id,
schemaname=agent_identifier,
agentappid=client_id,
tenantid=tenant_id,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
resolved_environment_id = copilot_studio_settings.get("environmentid")
resolved_agent_identifier = copilot_studio_settings.get("schemaname")
resolved_client_id = copilot_studio_settings.get("agentappid")
resolved_tenant_id = copilot_studio_settings.get("tenantid")
if not settings:
if not resolved_environment_id:
raise ValueError(
"Copilot Studio environment ID is required. Set via 'environment_id' parameter "
"or 'COPILOTSTUDIOAGENT__ENVIRONMENTID' environment variable."
)
if not resolved_agent_identifier:
raise ValueError(
"Copilot Studio agent identifier/schema name is required. Set via 'agent_identifier' parameter "
"or 'COPILOTSTUDIOAGENT__SCHEMANAME' environment variable."
)
settings = ConnectionSettings(
environment_id=resolved_environment_id,
agent_identifier=resolved_agent_identifier,
cloud=cloud,
copilot_agent_type=agent_type,
custom_power_platform_cloud=custom_power_platform_cloud,
)
if not token:
if not resolved_client_id:
raise ValueError(
"Copilot Studio client ID is required. Set via 'client_id' parameter "
"or 'COPILOTSTUDIOAGENT__AGENTAPPID' environment variable."
)
if not resolved_tenant_id:
raise ValueError(
"Copilot Studio tenant ID is required. Set via 'tenant_id' parameter "
"or 'COPILOTSTUDIOAGENT__TENANTID' environment variable."
)
token = acquire_token(
client_id=resolved_client_id,
tenant_id=resolved_tenant_id,
username=username,
token_cache=token_cache,
scopes=scopes,
)
client = CopilotClient(settings=settings, token=token)
self.client = client
self.cloud = cloud
self.agent_type = agent_type
self.custom_power_platform_cloud = custom_power_platform_cloud
self.username = username
self.token_cache = token_cache
self.scopes = scopes
@overload
def run(
self,
messages: AgentRunInputs | None = None,
*,
stream: Literal[False] = False,
session: AgentSession | None = None,
) -> Awaitable[AgentResponse]: ...
@overload
def run(
self,
messages: AgentRunInputs | None = None,
*,
stream: Literal[True],
session: AgentSession | None = None,
) -> ResponseStream[AgentResponseUpdate, AgentResponse]: ...
def run(
self,
messages: AgentRunInputs | None = None,
*,
stream: bool = False,
session: AgentSession | None = None,
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
"""Get a response from the agent.
This method returns the final result of the agent's execution
as a single AgentResponse object. When stream=True, it returns
a ResponseStream that yields AgentResponseUpdate objects.
Args:
messages: The message(s) to send to the agent.
Keyword Args:
stream: Whether to stream the response. Defaults to False.
session: The conversation session associated with the message(s).
Returns:
When stream=False: An Awaitable[AgentResponse].
When stream=True: A ResponseStream of AgentResponseUpdate items.
"""
if stream:
return self._run_stream_impl(messages=messages, session=session)
return self._run_impl(messages=messages, session=session)
async def _run_impl(
self,
messages: AgentRunInputs | None = None,
*,
session: AgentSession | None = None,
) -> AgentResponse:
"""Non-streaming implementation of run."""
if not session:
session = self.create_session()
service_session_id = session.service_session_id
if service_session_id is None:
session.service_session_id = await self._start_new_conversation()
service_session_id = session.service_session_id
if not isinstance(service_session_id, str):
raise AgentException("CopilotStudioAgent requires service_session_id to be a string")
input_messages = normalize_messages(messages)
question = "\n".join([message.text for message in input_messages])
activities = self.client.ask_question(question, service_session_id)
response_messages: list[Message] = []
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 AgentResponse(messages=response_messages, response_id=response_id)
def _run_stream_impl(
self,
messages: AgentRunInputs | None = None,
*,
session: AgentSession | None = None,
) -> ResponseStream[AgentResponseUpdate, AgentResponse]:
"""Streaming implementation of run."""
async def _stream() -> AsyncIterable[AgentResponseUpdate]:
nonlocal session
if not session:
session = self.create_session()
service_session_id = session.service_session_id
if service_session_id is None:
session.service_session_id = await self._start_new_conversation()
service_session_id = session.service_session_id
if not isinstance(service_session_id, str):
raise AgentException("CopilotStudioAgent requires service_session_id to be a string")
input_messages = normalize_messages(messages)
question = "\n".join([message.text for message in input_messages])
activities = self.client.ask_question(question, service_session_id)
async for message in self._process_activities(activities, streaming=True):
yield AgentResponseUpdate(
role=message.role,
contents=message.contents,
author_name=message.author_name,
raw_representation=message.raw_representation,
response_id=message.message_id,
message_id=message.message_id,
)
def _finalize(updates: Sequence[AgentResponseUpdate]) -> AgentResponse[None]:
return AgentResponse.from_updates(updates)
return ResponseStream(_stream(), finalizer=_finalize)
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:
AgentException: 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 AgentException("Failed to start a new conversation.")
return conversation_id
async def _process_activities(self, activities: AsyncIterable[Any], streaming: bool) -> AsyncIterable[Message]:
"""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:
Message 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 Message(
role="assistant",
contents=[Content.from_text(activity.text)],
author_name=activity.from_property.name if activity.from_property else None,
message_id=activity.id,
raw_representation=activity,
)
@@ -0,0 +1,98 @@
[project]
name = "agent-framework-copilotstudio"
description = "Copilot Studio integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260709"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
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 :: 4 - Beta",
"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",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.11.0,<2",
"microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2",
]
[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
markers = [
"integration: marks tests as integration tests that require external services",
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_copilotstudio"]
[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]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_copilotstudio"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_copilotstudio --cov-report=term-missing:skip-covered tests'
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
@@ -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 AgentException
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 ValueError when client_id is missing."""
with pytest.raises(ValueError, 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 ValueError when tenant_id is missing."""
with pytest.raises(ValueError, 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 ValueError when client_id is None."""
with pytest.raises(ValueError, 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 ValueError when tenant_id is None."""
with pytest.raises(ValueError, 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(AgentException, 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(AgentException, 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,361 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from agent_framework import AgentResponse, AgentResponseUpdate, AgentSession, Content, Message
from agent_framework.exceptions import AgentException
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.load_settings")
def test_init_missing_environment_id(self, mock_load_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
mock_acquire_token.return_value = "fake-token"
mock_load_settings.return_value = {
"environmentid": None,
"schemaname": "test-bot",
"tenantid": "test-tenant",
"agentappid": "test-client",
}
with pytest.raises(ValueError, match="environment ID is required"):
CopilotStudioAgent()
@patch("agent_framework_copilotstudio._acquire_token.acquire_token")
@patch("agent_framework_copilotstudio._agent.load_settings")
def test_init_missing_bot_id(self, mock_load_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
mock_acquire_token.return_value = "fake-token"
mock_load_settings.return_value = {
"environmentid": "test-env",
"schemaname": None,
"tenantid": "test-tenant",
"agentappid": "test-client",
}
with pytest.raises(ValueError, match="agent identifier"):
CopilotStudioAgent()
@patch("agent_framework_copilotstudio._acquire_token.acquire_token")
@patch("agent_framework_copilotstudio._agent.load_settings")
def test_init_missing_tenant_id(self, mock_load_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
mock_acquire_token.return_value = "fake-token"
mock_load_settings.return_value = {
"environmentid": "test-env",
"schemaname": "test-bot",
"tenantid": None,
"agentappid": "test-client",
}
with pytest.raises(ValueError, match="tenant ID is required"):
CopilotStudioAgent()
@patch("agent_framework_copilotstudio._acquire_token.acquire_token")
@patch("agent_framework_copilotstudio._agent.load_settings")
def test_init_missing_client_id(self, mock_load_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
mock_acquire_token.return_value = "fake-token"
mock_load_settings.return_value = {
"environmentid": "test-env",
"schemaname": "test-bot",
"tenantid": "test-tenant",
"agentappid": None,
}
with pytest.raises(ValueError, 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.load_settings") as mock_load_settings:
mock_load_settings.return_value = {
"environmentid": "",
"schemaname": "test-bot",
"tenantid": "test-tenant",
"agentappid": "test-client",
}
with pytest.raises(ValueError, 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.load_settings") as mock_load_settings:
mock_load_settings.return_value = {
"environmentid": "test-env",
"schemaname": "",
"tenantid": "test-tenant",
"agentappid": "test-client",
}
with pytest.raises(ValueError, match="agent identifier"):
CopilotStudioAgent()
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, AgentResponse)
assert len(response.messages) == 1
content = response.messages[0].contents[0]
assert content.type == "text"
assert content.text == "Test response"
assert response.messages[0].role == "assistant"
async def test_run_with_chat_message(self, mock_copilot_client: MagicMock, mock_activity: MagicMock) -> None:
"""Test run method with 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])
chat_message = Message(role="user", contents=[Content.from_text("test message")])
response = await agent.run(chat_message)
assert isinstance(response, AgentResponse)
assert len(response.messages) == 1
content = response.messages[0].contents[0]
assert content.type == "text"
assert content.text == "Test response"
assert response.messages[0].role == "assistant"
async def test_run_with_session(self, mock_copilot_client: MagicMock, mock_activity: MagicMock) -> None:
"""Test run method with existing session."""
agent = CopilotStudioAgent(client=mock_copilot_client)
session = AgentSession()
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", session=session)
assert isinstance(response, AgentResponse)
assert len(response.messages) == 1
assert session.service_session_id == "test-conversation-id"
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(AgentException, match="Failed to start a new conversation"):
await agent.run("test message")
async def test_run_streaming_with_string_message(self, mock_copilot_client: MagicMock) -> None:
"""Test run(stream=True) 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("test message", stream=True):
assert isinstance(response, AgentResponseUpdate)
content = response.contents[0]
assert content.type == "text"
assert content.text == "Streaming response"
response_count += 1
assert response_count == 1
async def test_run_streaming_with_session(self, mock_copilot_client: MagicMock) -> None:
"""Test run(stream=True) method with existing session."""
agent = CopilotStudioAgent(client=mock_copilot_client)
session = AgentSession()
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("test message", session=session, stream=True):
assert isinstance(response, AgentResponseUpdate)
content = response.contents[0]
assert content.type == "text"
assert content.text == "Streaming response"
response_count += 1
assert response_count == 1
assert session.service_session_id == "test-conversation-id"
async def test_run_reuses_existing_conversation(
self, mock_copilot_client: MagicMock, mock_activity: MagicMock
) -> None:
"""Test run method reuses an existing conversation ID from the session."""
agent = CopilotStudioAgent(client=mock_copilot_client)
session = AgentSession()
session.service_session_id = "existing-conversation-id"
mock_copilot_client.ask_question.return_value = create_async_generator([mock_activity])
response = await agent.run("test message", session=session)
assert isinstance(response, AgentResponse)
assert session.service_session_id == "existing-conversation-id"
mock_copilot_client.start_conversation.assert_not_called()
mock_copilot_client.ask_question.assert_called_once_with("test message", "existing-conversation-id")
async def test_run_streaming_reuses_existing_conversation(self, mock_copilot_client: MagicMock) -> None:
"""Test run(stream=True) method reuses an existing conversation ID from the session."""
agent = CopilotStudioAgent(client=mock_copilot_client)
session = AgentSession()
session.service_session_id = "existing-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.ask_question.return_value = create_async_generator([typing_activity])
response_count = 0
async for response in agent.run("test message", session=session, stream=True):
assert isinstance(response, AgentResponseUpdate)
response_count += 1
assert response_count == 1
assert session.service_session_id == "existing-conversation-id"
mock_copilot_client.start_conversation.assert_not_called()
mock_copilot_client.ask_question.assert_called_once_with("test message", "existing-conversation-id")
async def test_run_streaming_no_typing_activity(self, mock_copilot_client: MagicMock) -> None:
"""Test run(stream=True) 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("test message", stream=True):
response_count += 1
assert response_count == 0
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, AgentResponse)
assert len(response.messages) == 2
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, AgentResponse)
assert len(response.messages) == 1
async def test_run_streaming_start_conversation_failure(self, mock_copilot_client: MagicMock) -> None:
"""Test run(stream=True) method when conversation start fails."""
agent = CopilotStudioAgent(client=mock_copilot_client)
mock_copilot_client.start_conversation.return_value = create_async_generator([])
with pytest.raises(AgentException, match="Failed to start a new conversation"):
async for _ in agent.run("test message", stream=True):
pass