diff --git a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/_conversation_endpoint_discovery.py b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/_conversation_endpoint_discovery.py new file mode 100644 index 000000000..5ed96edd1 --- /dev/null +++ b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/_conversation_endpoint_discovery.py @@ -0,0 +1,106 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from dataclasses import dataclass +from typing import Literal, Optional +from urllib.parse import quote, urlsplit, urlunsplit + +from .connection_settings import ConnectionSettings +from .errors import copilot_studio_errors +from .power_platform_environment import PowerPlatformEnvironment + +ConversationOperation = Literal["createConversation", "executeTurn", "subscribe"] +_CONVERSATION_ID = "{conversationId}" + + +def get_discovery_url(settings: ConnectionSettings) -> str: + url = urlsplit(PowerPlatformEnvironment.get_copilot_studio_connection_url(settings)) + return urlunsplit( + ( + url.scheme, + url.netloc, + f"/copilotstudio/agents/{quote(settings.agent_identifier, safe='')}/conversation-endpoints", + "", + "", + ) + ) + + +def _validate_url( + value: object, discovery_url: str, requires_conversation_id: bool +) -> str: + error = str(copilot_studio_errors.InvalidDiscoveryOperationUrl) + if not isinstance(value, str) or not value.strip(): + raise ValueError(error) + + remainder = value.replace(_CONVERSATION_ID, "") + if ( + requires_conversation_id != (_CONVERSATION_ID in value) + or "{" in remainder + or "}" in remainder + or "\\" in value + or any(character.isspace() or ord(character) < 32 for character in value) + ): + raise ValueError(error) + + try: + discovery = urlsplit(discovery_url) + url = urlsplit(value.replace(_CONVERSATION_ID, "conversation")) + host = discovery.hostname or "" + suffix_index = host.find(".environment.") + suffix = host[suffix_index:] if suffix_index >= 0 else "." + host + valid = ( + url.scheme == "https" + and (url.port if url.port is not None else 443) + == (discovery.port if discovery.port is not None else 443) + and bool(url.hostname) + and url.hostname.endswith(suffix) + and url.username is None + and url.password is None + and not url.fragment + ) + except ValueError as exc: + raise ValueError(error) from exc + if not valid: + raise ValueError(error) + return value + + +@dataclass(frozen=True) +class ConversationEndpoints: + create_conversation: str + execute_turn: str + continue_turn: str + subscribe: Optional[str] + + @classmethod + def from_response(cls, data: object, discovery_url: str) -> "ConversationEndpoints": + if not isinstance(data, dict): + raise ValueError(str(copilot_studio_errors.InvalidDiscoveryResponse)) + + return cls( + create_conversation=_validate_url( + data.get("createConversation"), discovery_url, False + ), + execute_turn=_validate_url(data.get("executeTurn"), discovery_url, True), + continue_turn=_validate_url(data.get("continueTurn"), discovery_url, True), + subscribe=( + _validate_url(data["subscribe"], discovery_url, True) + if data.get("subscribe") is not None + else None + ), + ) + + def get_url( + self, operation: ConversationOperation, conversation_id: Optional[str] = None + ) -> str: + if operation == "createConversation": + return self.create_conversation + if not conversation_id: + raise ValueError(str(copilot_studio_errors.DiscoveryConversationIdRequired)) + url = self.execute_turn if operation == "executeTurn" else self.subscribe + if url is None: + raise NotImplementedError( + str(copilot_studio_errors.DiscoverySubscribeNotSupported) + ) + return url.replace(_CONVERSATION_ID, quote(conversation_id, safe="")) diff --git a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/copilot_client.py b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/copilot_client.py index f358fdb8c..2994f1906 100644 --- a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/copilot_client.py +++ b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/copilot_client.py @@ -1,13 +1,23 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -import aiohttp +import asyncio +import json import logging from typing import AsyncIterable, Optional +import aiohttp + from microsoft_agents.activity import Activity, ActivityTypes, ConversationAccount +from ._conversation_endpoint_discovery import ( + ConversationEndpoints, + ConversationOperation, + get_discovery_url, +) +from .agent_type import AgentType from .connection_settings import ConnectionSettings +from .errors import copilot_studio_errors from .execute_turn_request import ExecuteTurnRequest from .power_platform_environment import PowerPlatformEnvironment from .start_request import StartRequest @@ -34,6 +44,91 @@ def __init__( self._logger = logging.getLogger(__name__) self.conversation_id = "" self._island_experimental_url = "" + self._discovery_lock = asyncio.Lock() + self._discovered_direct_connect_url: Optional[str] = None + self._discovered_endpoints: Optional[ConversationEndpoints] = None + + async def _resolve_connection_url( + self, conversation_id: Optional[str] = None, subscribe: bool = False + ) -> str: + if ( + self.settings.copilot_agent_type != AgentType.PREBUILT + and not self.settings.direct_connect_url + ): + async with self._discovery_lock: + if ( + self.settings.copilot_agent_type != AgentType.PREBUILT + and not self.settings.direct_connect_url + ): + endpoints = await self._discover_endpoints() + if ( + self.settings.copilot_agent_type != AgentType.PREBUILT + and not self.settings.direct_connect_url + ): + self._discovered_endpoints = endpoints + self._discovered_direct_connect_url = ( + endpoints.create_conversation + ) + self.settings.direct_connect_url = endpoints.create_conversation + + if ( + self.settings.copilot_agent_type != AgentType.PREBUILT + and self._discovered_endpoints is not None + and self.settings.direct_connect_url == self._discovered_direct_connect_url + ): + operation: ConversationOperation = ( + "subscribe" + if subscribe + else "executeTurn" if conversation_id else "createConversation" + ) + return self._discovered_endpoints.get_url(operation, conversation_id) + return PowerPlatformEnvironment.get_copilot_studio_connection_url( + settings=self.settings, + conversation_id=conversation_id, + create_subscribe_link=subscribe, + ) + + async def _discover_endpoints(self) -> ConversationEndpoints: + if not self._token or not self._token.strip(): + raise ValueError(str(copilot_studio_errors.DiscoveryAccessTokenRequired)) + + url = get_discovery_url(self.settings) + headers = { + "Authorization": f"Bearer {self._token}", + "Accept": self.APPLICATION_JSON_TYPE, + "User-Agent": UserAgentHelper.get_user_agent_header(), + } + if self.settings.enable_diagnostics: + self._logger.debug(">>> SEND TO %s", url) + + async with aiohttp.ClientSession( + **self.settings.client_session_settings + ) as session: + async with session.get( + url, headers=headers, allow_redirects=False + ) as response: + if response.status != 200: + self._logger.error( + "Error discovering conversation endpoints: %s", response.status + ) + raise aiohttp.ClientResponseError( + response.request_info, + response.history, + status=response.status, + message="Error discovering conversation endpoints", + headers=response.headers, + ) + try: + data = await response.json() + except ( + json.JSONDecodeError, + UnicodeDecodeError, + aiohttp.ContentTypeError, + ) as exc: + raise ValueError( + str(copilot_studio_errors.InvalidDiscoveryResponse) + ) from exc + return ConversationEndpoints.from_response(data, url) async def post_request( self, url: str, data: dict, headers: dict @@ -112,9 +207,7 @@ async def start_conversation( :return: An asynchronous iterable of Activity objects received in the response. """ - url = PowerPlatformEnvironment.get_copilot_studio_connection_url( - settings=self.settings - ) + url = await self._resolve_connection_url() data = {"emitStartConversationEvent": emit_start_conversation_event} headers = { "Content-Type": self.APPLICATION_JSON_TYPE, @@ -164,9 +257,7 @@ async def ask_question_with_activity( activity.conversation.id or self._current_conversation_id ) - url = PowerPlatformEnvironment.get_copilot_studio_connection_url( - settings=self.settings, conversation_id=local_conversation_id - ) + url = await self._resolve_connection_url(local_conversation_id) data = ExecuteTurnRequest(activity=activity).model_dump( mode="json", by_alias=True, exclude_unset=True ) @@ -188,9 +279,7 @@ async def start_conversation_with_request( :return: An asynchronous iterable of Activity objects received in the response. """ - url = PowerPlatformEnvironment.get_copilot_studio_connection_url( - settings=self.settings - ) + url = await self._resolve_connection_url() data = start_request.model_dump(mode="json", by_alias=True, exclude_unset=True) headers = { "Content-Type": self.APPLICATION_JSON_TYPE, @@ -249,12 +338,7 @@ async def subscribe( if not conversation_id: raise ValueError("CopilotClient.subscribe: conversation_id cannot be None") - # Build the subscribe URL using the environment helper to ensure correct path and query handling - url = PowerPlatformEnvironment.get_copilot_studio_connection_url( - settings=self.settings, - conversation_id=conversation_id, - create_subscribe_link=True, - ) + url = await self._resolve_connection_url(conversation_id, subscribe=True) headers = { "Content-Type": self.APPLICATION_JSON_TYPE, "Authorization": f"Bearer {self._token}", diff --git a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/errors/error_resources.py b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/errors/error_resources.py index 8f60434f0..59afb9b3e 100644 --- a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/errors/error_resources.py +++ b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/errors/error_resources.py @@ -52,6 +52,31 @@ class CopilotStudioErrorResources: -65006, ) + InvalidDiscoveryResponse = ErrorMessage( + "Conversation endpoint discovery returned an invalid JSON object.", + -65007, + ) + + InvalidDiscoveryOperationUrl = ErrorMessage( + "Conversation endpoint discovery returned an invalid operation URL.", + -65008, + ) + + DiscoveryConversationIdRequired = ErrorMessage( + "A conversation ID is required for this operation.", + -65009, + ) + + DiscoverySubscribeNotSupported = ErrorMessage( + "The agent does not advertise a subscribe endpoint.", + -65010, + ) + + DiscoveryAccessTokenRequired = ErrorMessage( + "A non-empty access token is required for conversation endpoint discovery.", + -65011, + ) + def __init__(self): """Initialize CopilotStudioErrorResources.""" pass diff --git a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/power_platform_environment.py b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/power_platform_environment.py index 8a5f76e8b..8c17622aa 100644 --- a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/power_platform_environment.py +++ b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/power_platform_environment.py @@ -322,6 +322,7 @@ def get_environment_endpoint( if cloud == PowerPlatformCloud.OTHER and not cloud_base_address: raise ValueError(str(copilot_studio_errors.CloudBaseAddressRequired)) cloud_base_address = cloud_base_address or "api.unknown.powerplatform.com" + cloud_base_address = urlparse(cloud_base_address).netloc or cloud_base_address normalized_resource_id = environment_id.lower().replace("-", "") id_suffix_length = PowerPlatformEnvironment.get_id_suffix_length(cloud) hex_prefix = normalized_resource_id[:-id_suffix_length] diff --git a/libraries/microsoft-agents-copilotstudio-client/readme.md b/libraries/microsoft-agents-copilotstudio-client/readme.md index 3622306ec..0b3759306 100644 --- a/libraries/microsoft-agents-copilotstudio-client/readme.md +++ b/libraries/microsoft-agents-copilotstudio-client/readme.md @@ -209,6 +209,28 @@ def create_client(): return copilot_client ``` +#### Conversation Endpoint Discovery + +For published agents without a `direct_connect_url`, the client first sends an +authenticated `GET` to +`https:///copilotstudio/agents//conversation-endpoints`. +The host is selected from the configured environment and cloud. Discovery uses the +client's bearer token and the configured `client_session_settings`. + +The client validates all advertised endpoints before storing them and uses each +operation URL as returned, preserving its query string and escaping conversation +IDs when substituting `{conversationId}`. `createConversation`, `executeTurn`, and +`continueTurn` are required; `subscribe` is optional and subscription fails +explicitly when the agent does not advertise it. The client has no continue-turn +operation. + +Successful discovery is reused for the lifetime of the client, with concurrent +calls coordinated to avoid duplicate initialization. Failures and cancellation +allow a later call to retry. The discovered create URL is written to +`settings.direct_connect_url`; clearing that value triggers discovery again. +Assigning a different explicit URL switches back to legacy direct-URL routing. +Explicit direct URLs and prebuilt agents bypass discovery entirely. + #### DirectConnect URL Mode (Simplified Setup) For simplified setup, you can use a DirectConnect URL instead of environment-based configuration: diff --git a/test_samples/copilot_studio_client_sample/README.md b/test_samples/copilot_studio_client_sample/README.md new file mode 100644 index 000000000..2c7a3f490 --- /dev/null +++ b/test_samples/copilot_studio_client_sample/README.md @@ -0,0 +1,60 @@ +# Copilot Studio console client + +This sample signs in a user interactively and connects to a published Copilot +Studio agent. With `DIRECT_CONNECT_URL` empty, the client discovers the agent's +conversation endpoints before starting the conversation. + +## Setup + +Use Python 3.10 or later. From the repository root, create a virtual environment +and install the local SDK and sample dependencies: + +```powershell +python -m venv .venv +.\.venv\Scripts\Activate.ps1 +python -m pip install -e .\libraries\microsoft-agents-activity -e .\libraries\microsoft-agents-hosting-core -e .\libraries\microsoft-agents-copilotstudio-client --config-settings editable_mode=compat msal msal-extensions +``` + +Copy `env.TEMPLATE` to `.env` in this directory and fill in the resource IDs: + +```dotenv +ENVIRONMENT_ID= +AGENT_IDENTIFIER= +TENANT_ID= +APP_CLIENT_ID= +TOKEN_CACHE_PATH= +CLOUD=TEST +COPILOT_AGENT_TYPE=PUBLISHED +CUSTOM_POWER_PLATFORM_CLOUD= +DIRECT_CONNECT_URL= +USE_EXPERIMENTAL_ENDPOINT=false +ENABLE_DIAGNOSTICS=false +``` + +Use `CLOUD=TEST` for an agent in the Test cloud, or `CLOUD=PROD` for production +(the template's default). Cloud enum names are uppercase. The sample derives +the token scope from these settings; Test uses +`https://api.test.powerplatform.com/.default`. + +The Entra application must support public-client interactive authentication with +the `http://localhost` redirect URI and the delegated +`CopilotStudio.Copilots.Invoke` permission for the target Power Platform resource, +with consent as required by your tenant. Sign in as a user who can invoke the +published agent. This sample does not use a client secret. An app-only token with +an application role is not equivalent to a delegated user token with that scope. +Changing the cloud or adding an application permission does not grant agent access. + +`.env` and the default token cache under `bin` are local, Git-ignored files. +Do not commit secrets or tokens. + +## Run + +From the repository root with the virtual environment activated: + +```powershell +python .\test_samples\copilot_studio_client_sample\app.py +``` + +Complete the browser sign-in, then enter messages at the `user>` prompt. Use +Ctrl+C to stop. Set `DIRECT_CONNECT_URL` only when you intentionally want to +bypass discovery; prebuilt agents also retain their existing routing. diff --git a/test_samples/copilot_studio_client_sample/app.py b/test_samples/copilot_studio_client_sample/app.py index a020e2dbd..391787a06 100644 --- a/test_samples/copilot_studio_client_sample/app.py +++ b/test_samples/copilot_studio_client_sample/app.py @@ -9,9 +9,6 @@ from chat_console_service import ChatConsoleService from config import McsConnectionSettings -load_dotenv(path.join(path.dirname(__file__), ".env")) -mcs_connection_settings = McsConnectionSettings() - def acquire_token(mcs_settings: McsConnectionSettings, cache_path: str) -> str: cache = get_msal_token_cache(cache_path) @@ -21,15 +18,16 @@ def acquire_token(mcs_settings: McsConnectionSettings, cache_path: str) -> str: token_cache=cache, ) - token_scopes = ["https://api.powerplatform.com/.default"] + token_scopes = [CopilotClient.scope_from_settings(mcs_settings)] accounts = app.get_accounts() + result = None if accounts: # If so, you could then somehow display these accounts and let end user choose chosen = accounts[0] result = app.acquire_token_silent(scopes=token_scopes, account=chosen) - else: + if not result or "access_token" not in result: # At this point, you can save you can update your cache if you are using token caching # check result variable, if its None then you should interactively acquire a token # So no suitable token exists in cache. Let's get a new one from Microsoft Entra. @@ -53,10 +51,12 @@ def create_mcs_client(connection_settings: ConnectionSettings) -> CopilotClient: return CopilotClient(connection_settings, token) -loop = asyncio.get_event_loop() -try: - loop.run_until_complete( - ChatConsoleService(create_mcs_client(mcs_connection_settings)).start_service() - ) -finally: - loop.close() +def main() -> None: + load_dotenv(path.join(path.dirname(__file__), ".env")) + connection_settings = McsConnectionSettings() + client = create_mcs_client(connection_settings) + asyncio.run(ChatConsoleService(client).start_service()) + + +if __name__ == "__main__": + main() diff --git a/test_samples/copilot_studio_client_sample/env.TEMPLATE b/test_samples/copilot_studio_client_sample/env.TEMPLATE index 1986e4bb0..2b063e472 100644 --- a/test_samples/copilot_studio_client_sample/env.TEMPLATE +++ b/test_samples/copilot_studio_client_sample/env.TEMPLATE @@ -3,4 +3,11 @@ ENVIRONMENT_ID= AGENT_IDENTIFIER= TENANT_ID= APP_CLIENT_ID= -TOKEN_CACHE_PATH= \ No newline at end of file +TOKEN_CACHE_PATH= +CLOUD=PROD +COPILOT_AGENT_TYPE=PUBLISHED +CUSTOM_POWER_PLATFORM_CLOUD= +# Leave empty to discover the agent's conversation endpoints. +DIRECT_CONNECT_URL= +USE_EXPERIMENTAL_ENDPOINT=false +ENABLE_DIAGNOSTICS=false \ No newline at end of file diff --git a/tests/copilotstudio_client/test_console_sample.py b/tests/copilotstudio_client/test_console_sample.py new file mode 100644 index 000000000..5107f2450 --- /dev/null +++ b/tests/copilotstudio_client/test_console_sample.py @@ -0,0 +1,83 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import importlib.util +from pathlib import Path + +import pytest + +from microsoft_agents.copilotstudio.client import PowerPlatformCloud + + +@pytest.fixture +def sample(monkeypatch): + sample_path = ( + Path(__file__).resolve().parents[2] + / "test_samples" + / "copilot_studio_client_sample" + ) + monkeypatch.syspath_prepend(str(sample_path)) + spec = importlib.util.spec_from_file_location( + "copilot_studio_console_sample", sample_path / "app.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.mark.parametrize("cloud", [PowerPlatformCloud.TEST, PowerPlatformCloud.PROD]) +@pytest.mark.parametrize( + "cached", + [None, {"error": "interaction_required"}, {"access_token": "cached-token"}], +) +def test_sample_uses_configured_scope_and_interactive_fallback( + sample, mocker, cloud, cached +): + settings = sample.McsConnectionSettings( + app_client_id="client", + tenant_id="tenant", + environment_id="environment", + agent_identifier="agent", + cloud=cloud, + ) + mocker.patch.object(sample, "get_msal_token_cache") + application = mocker.patch.object(sample, "PublicClientApplication").return_value + application.get_accounts.return_value = ["account"] + application.acquire_token_silent.return_value = cached + application.acquire_token_interactive.return_value = {"access_token": "new-token"} + + token = sample.acquire_token(settings, "unused-cache") + + scope = ( + "https://api.test.powerplatform.com/.default" + if cloud == PowerPlatformCloud.TEST + else "https://api.powerplatform.com/.default" + ) + application.acquire_token_silent.assert_called_once_with( + scopes=[scope], account="account" + ) + if cached and "access_token" in cached: + assert token == "cached-token" + application.acquire_token_interactive.assert_not_called() + else: + assert token == "new-token" + application.acquire_token_interactive.assert_called_once_with(scopes=[scope]) + + +def test_sample_signs_in_interactively_without_cached_accounts(sample, mocker): + settings = sample.McsConnectionSettings( + app_client_id="client", + tenant_id="tenant", + environment_id="environment", + agent_identifier="agent", + cloud=PowerPlatformCloud.TEST, + ) + mocker.patch.object(sample, "get_msal_token_cache") + application = mocker.patch.object(sample, "PublicClientApplication").return_value + application.get_accounts.return_value = [] + application.acquire_token_interactive.return_value = {"access_token": "new-token"} + assert sample.acquire_token(settings, "unused-cache") == "new-token" + application.acquire_token_silent.assert_not_called() + application.acquire_token_interactive.assert_called_once_with( + scopes=["https://api.test.powerplatform.com/.default"] + ) diff --git a/tests/copilotstudio_client/test_conversation_endpoint_discovery.py b/tests/copilotstudio_client/test_conversation_endpoint_discovery.py new file mode 100644 index 000000000..510a082b8 --- /dev/null +++ b/tests/copilotstudio_client/test_conversation_endpoint_discovery.py @@ -0,0 +1,459 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import asyncio +import json +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock +from urllib.parse import urlsplit + +import aiohttp +import pytest + +from microsoft_agents.activity import Activity +from microsoft_agents.copilotstudio.client import ( + AgentType, + ConnectionSettings, + CopilotClient, + PowerPlatformCloud, + PowerPlatformEnvironment, + StartRequest, +) +from microsoft_agents.copilotstudio.client._conversation_endpoint_discovery import ( + ConversationEndpoints, + get_discovery_url, +) + +HOST = "default5cae182f63ff49f99f353acc2724e73.8.environment.api.test.powerplatform.com" +DISCOVERY_URL = f"https://{HOST}/copilotstudio/agents/test/conversation-endpoints" +ROOT = f"https://{HOST}/copilotstudio/dataverse-backed/authenticated/bots/test/conversations" + + +@pytest.fixture +def settings(): + return ConnectionSettings( + "Default-5cae182f-63ff-49f9-9f35-3acc2724e738", + "test", + cloud=PowerPlatformCloud.TEST, + ) + + +@pytest.fixture +def endpoints(): + return { + "createConversation": ROOT + "?api-version=create&opaque=%2F+%20", + "executeTurn": ROOT + "/{conversationId}?api-version=execute", + "continueTurn": ROOT + "/{conversationId}/continue?api-version=continue", + "subscribe": ROOT + "/{conversationId}/subscribe?api-version=subscribe", + } + + +@pytest.fixture +def transport(mocker, endpoints): + session = MagicMock(spec=aiohttp.ClientSession) + session.__aenter__.return_value = session + response = MagicMock(spec=aiohttp.ClientResponse) + response.status = 200 + response.headers = {} + response.json = AsyncMock(return_value=endpoints) + + @asynccontextmanager + async def get(*args, **kwargs): + yield response + + session.get.side_effect = get + factory = mocker.patch("aiohttp.ClientSession", return_value=session) + return session, response, factory + + +@pytest.mark.parametrize( + "cloud,suffix", + [ + (PowerPlatformCloud.TEST, ".8.environment.api.test.powerplatform.com"), + (PowerPlatformCloud.PROD, ".38.environment.api.powerplatform.com"), + (PowerPlatformCloud.GOV, ".8.environment.api.gov.powerplatform.microsoft.us"), + (PowerPlatformCloud.OTHER, ".8.environment.example.com"), + ], +) +def test_discovery_url_uses_cloud_and_escapes_schema(settings, cloud, suffix): + settings.cloud = cloud + settings.custom_power_platform_cloud = "https://example.com" + settings.agent_identifier = "schema /?#" + url = urlsplit(get_discovery_url(settings)) + assert url.hostname.endswith(suffix) + assert url.path == "/copilotstudio/agents/schema%20%2F%3F%23/conversation-endpoints" + assert not url.query + assert not url.fragment + + +@pytest.mark.parametrize("agentic", [False, True]) +@pytest.mark.asyncio +async def test_routes_all_operations_and_reuses_discovery( + settings, endpoints, transport, mocker, agentic +): + if agentic: + endpoints.update( + { + key: value.replace("/dataverse-backed", "/agenticruntime/3p") + for key, value in endpoints.items() + } + ) + client = CopilotClient(settings, "test-token") + sent = [] + + async def post(url, data, headers): + sent.append((url, data, headers)) + yield Activity(type="message", text="response", conversation={"id": "a/b ?#"}) + + mocker.patch.object(client, "post_request", side_effect=post) + assert len([item async for item in client.start_conversation()]) == 1 + assert ( + len( + [ + item + async for item in client.start_conversation_with_request(StartRequest()) + ] + ) + == 1 + ) + activity = Activity(type="message", text="question", conversation={"id": "a/b ?#"}) + assert len([item async for item in client.send_activity(activity)]) == 1 + assert len([item async for item in client.execute("a/b ?#", activity)]) == 1 + assert len([item async for item in client.ask_question("question", "a/b ?#")]) == 1 + + session, response, _ = transport + + async def content(): + yield b"id: event-1\n" + yield b"event: activity\n" + yield b'data: {"type":"message","text":"event"}\n' + + response.content = content() + events = [event async for event in client.subscribe("a/b ?#", "previous-event")] + assert events[0].event_id == "event-1" + assert events[0].activity.text == "event" + execute_url = endpoints["executeTurn"].replace("{conversationId}", "a%2Fb%20%3F%23") + assert [item[0] for item in sent] == [ + endpoints["createConversation"], + endpoints["createConversation"], + execute_url, + execute_url, + execute_url, + ] + assert settings.direct_connect_url == endpoints["createConversation"] + assert session.get.call_count == 2 + discovery_call, subscribe_call = session.get.call_args_list + assert discovery_call.args == (DISCOVERY_URL,) + assert discovery_call.kwargs["allow_redirects"] is False + assert discovery_call.kwargs["headers"]["Authorization"] == "Bearer test-token" + assert discovery_call.kwargs["headers"]["Accept"] == "application/json" + assert "agents-sdk-python" in discovery_call.kwargs["headers"]["User-Agent"] + assert subscribe_call.args == ( + endpoints["subscribe"].replace("{conversationId}", "a%2Fb%20%3F%23"), + ) + assert subscribe_call.kwargs["headers"]["Last-Event-ID"] == "previous-event" + + +@pytest.mark.parametrize( + "operation", ["createConversation", "executeTurn", "continueTurn", "subscribe"] +) +@pytest.mark.parametrize( + "bad_url", + [ + None, + "", + " ", + 42, + {}, + "/relative/{conversationId}", + "http://{host}/conversations/{conversationId}", + "https://evil.example/conversations/{conversationId}", + "https://{host}.evil.example/conversations/{conversationId}", + "https://evilenvironment.api.test.powerplatform.com/{conversationId}", + "https://{host}:444/conversations/{conversationId}", + "https://{host}:0/conversations/{conversationId}", + "https://user:password@{host}/conversations/{conversationId}", + "https://@{host}/conversations/{conversationId}", + "https://{host}/conversations/{conversationId}#fragment", + "https://{host}/conversations/{unknown}", + "https://{host}/conversations/{conversationId}/{", + "https://{host}/conversations/{conversationId}/}", + "https://{host}:invalid/conversations/{conversationId}", + "https://[invalid/conversations/{conversationId}", + "https://{host}\\@evil.example/{conversationId}", + "https://{host}\n.evil.example/{conversationId}", + ], +) +def test_rejects_invalid_operation_urls(endpoints, operation, bad_url): + if bad_url is None and operation == "subscribe": + endpoints["subscribe"] = None + assert ( + ConversationEndpoints.from_response(endpoints, DISCOVERY_URL).subscribe + is None + ) + return + endpoints[operation] = ( + bad_url.replace("{host}", HOST) if isinstance(bad_url, str) else bad_url + ) + with pytest.raises(ValueError, match="invalid operation URL"): + ConversationEndpoints.from_response(endpoints, DISCOVERY_URL) + + +@pytest.mark.parametrize("data", [None, [], "", 42, True]) +def test_rejects_non_object_response(data): + with pytest.raises(ValueError, match="invalid JSON object"): + ConversationEndpoints.from_response(data, DISCOVERY_URL) + + +@pytest.mark.parametrize( + "operation", ["createConversation", "executeTurn", "continueTurn"] +) +def test_required_operation_missing(endpoints, operation): + del endpoints[operation] + with pytest.raises(ValueError, match="invalid operation URL"): + ConversationEndpoints.from_response(endpoints, DISCOVERY_URL) + + +@pytest.mark.parametrize("operation", ["executeTurn", "continueTurn", "subscribe"]) +def test_conversation_placeholder_is_required(endpoints, operation): + endpoints[operation] = ROOT + "/conversation" + with pytest.raises(ValueError, match="invalid operation URL"): + ConversationEndpoints.from_response(endpoints, DISCOVERY_URL) + + +def test_create_must_not_contain_conversation_placeholder(endpoints): + endpoints["createConversation"] = ROOT + "/{conversationId}" + with pytest.raises(ValueError, match="invalid operation URL"): + ConversationEndpoints.from_response(endpoints, DISCOVERY_URL) + + +@pytest.mark.parametrize("missing", [True, False]) +@pytest.mark.asyncio +async def test_subscribe_is_optional(settings, endpoints, transport, missing): + if missing: + del endpoints["subscribe"] + else: + endpoints["subscribe"] = None + client = CopilotClient(settings, "token") + assert await client._resolve_connection_url() == endpoints["createConversation"] + with pytest.raises(NotImplementedError, match="does not advertise"): + await client._resolve_connection_url("conversation", subscribe=True) + transport[0].get.assert_called_once() + + +def test_conversation_id_required_for_operation(endpoints): + urls = ConversationEndpoints.from_response(endpoints, DISCOVERY_URL) + for operation in ("executeTurn", "subscribe"): + with pytest.raises(ValueError, match="conversation ID is required"): + urls.get_url(operation) + + +@pytest.mark.asyncio +async def test_allows_other_environment_hosts_in_same_cloud( + settings, endpoints, transport +): + endpoints.update( + { + key: value.replace(HOST, "other.1.environment.api.test.powerplatform.com") + for key, value in endpoints.items() + } + ) + client = CopilotClient(settings, "token") + assert await client._resolve_connection_url() == endpoints["createConversation"] + + +@pytest.mark.asyncio +async def test_concurrent_calls_share_discovery(settings, endpoints, transport): + entered = asyncio.Event() + release = asyncio.Event() + + async def read_json(): + entered.set() + await release.wait() + return endpoints + + transport[1].json.side_effect = read_json + client = CopilotClient(settings, "token") + first = asyncio.create_task(client._resolve_connection_url()) + await entered.wait() + second = asyncio.create_task(client._resolve_connection_url("conversation")) + release.set() + results = await asyncio.gather(first, second) + assert results == [ + endpoints["createConversation"], + endpoints["executeTurn"].replace("{conversationId}", "conversation"), + ] + transport[0].get.assert_called_once() + + +@pytest.mark.parametrize("status", [301, 302, 401, 403, 404, 500]) +@pytest.mark.asyncio +async def test_http_failure_is_explicit_and_retryable( + settings, endpoints, transport, status +): + client = CopilotClient(settings, "token") + transport[1].status = status + with pytest.raises(aiohttp.ClientResponseError) as error: + await client._resolve_connection_url() + assert error.value.status == status + assert settings.direct_connect_url is None + transport[1].status = 200 + assert await client._resolve_connection_url() == endpoints["createConversation"] + assert transport[0].get.call_count == 2 + + +@pytest.mark.parametrize("invalid", ["json", "content-type", "endpoints", "empty"]) +@pytest.mark.asyncio +async def test_invalid_response_is_retryable(settings, endpoints, transport, invalid): + client = CopilotClient(settings, "token") + if invalid == "json": + transport[1].json.side_effect = json.JSONDecodeError("invalid", "x", 0) + elif invalid == "content-type": + transport[1].json.side_effect = aiohttp.ContentTypeError( + MagicMock(), (), message="Not JSON" + ) + else: + transport[1].json.return_value = ( + {"createConversation": "https://evil.example"} + if invalid == "endpoints" + else None + ) + with pytest.raises(ValueError): + await client._resolve_connection_url() + assert settings.direct_connect_url is None + transport[1].json.side_effect = None + transport[1].json.return_value = endpoints + assert await client._resolve_connection_url() == endpoints["createConversation"] + + +@pytest.mark.parametrize("token", ["", " \t", None]) +@pytest.mark.asyncio +async def test_empty_token_fails_before_network(settings, transport, token): + client = CopilotClient(settings, token) + with pytest.raises(ValueError, match="non-empty access token"): + await client._resolve_connection_url() + transport[2].assert_not_called() + + +@pytest.mark.parametrize("bypass", ["direct", "prebuilt"]) +@pytest.mark.asyncio +async def test_direct_and_prebuilt_bypass_discovery(settings, transport, bypass): + if bypass == "direct": + settings.direct_connect_url = "https://custom.example/bot" + else: + settings.copilot_agent_type = AgentType.PREBUILT + client = CopilotClient(settings, "") + for conversation_id, subscribe in [(None, False), ("conv", False), ("conv", True)]: + expected = PowerPlatformEnvironment.get_copilot_studio_connection_url( + settings, conversation_id, create_subscribe_link=subscribe + ) + assert ( + await client._resolve_connection_url(conversation_id, subscribe) == expected + ) + transport[2].assert_not_called() + + +@pytest.mark.asyncio +async def test_explicit_url_override_and_clearing_trigger_correct_routing( + settings, endpoints, transport +): + client = CopilotClient(settings, "token") + await client._resolve_connection_url() + settings.direct_connect_url = "https://custom.example/bot?original=query" + expected = PowerPlatformEnvironment.get_copilot_studio_connection_url( + settings, "id" + ) + assert await client._resolve_connection_url("id") == expected + transport[0].get.assert_called_once() + settings.direct_connect_url = "" + assert await client._resolve_connection_url() == endpoints["createConversation"] + assert transport[0].get.call_count == 2 + + +@pytest.mark.parametrize("change", ["direct", "prebuilt"]) +@pytest.mark.asyncio +async def test_settings_changed_during_discovery_are_preserved( + settings, endpoints, transport, change +): + async def read_json(): + if change == "direct": + settings.direct_connect_url = "https://custom.example/bot" + else: + settings.copilot_agent_type = AgentType.PREBUILT + return endpoints + + transport[1].json.side_effect = read_json + client = CopilotClient(settings, "token") + url = await client._resolve_connection_url("conversation") + assert url == PowerPlatformEnvironment.get_copilot_studio_connection_url( + settings, "conversation" + ) + assert client._discovered_endpoints is None + + +@pytest.mark.asyncio +async def test_cancellation_releases_discovery_lock(settings, endpoints, transport): + entered = asyncio.Event() + blocked = asyncio.Event() + + async def read_json(): + entered.set() + await blocked.wait() + return endpoints + + transport[1].json.side_effect = read_json + client = CopilotClient(settings, "token") + task = asyncio.create_task(client._resolve_connection_url()) + await entered.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert settings.direct_connect_url is None + transport[1].json.side_effect = None + assert await client._resolve_connection_url() == endpoints["createConversation"] + + +@pytest.mark.asyncio +async def test_session_options_are_honored(settings, transport): + timeout = aiohttp.ClientTimeout(total=30) + settings.client_session_settings = {"timeout": timeout} + await CopilotClient(settings, "token")._resolve_connection_url() + transport[2].assert_called_once_with(timeout=timeout) + + +@pytest.mark.asyncio +async def test_discovery_state_is_per_client(settings, endpoints, transport): + first = CopilotClient(settings, "first-token") + await first._resolve_connection_url() + other_settings = ConnectionSettings( + settings.environment_id, settings.agent_identifier, cloud=settings.cloud + ) + second = CopilotClient(other_settings, "second-token") + await second._resolve_connection_url() + assert transport[0].get.call_count == 2 + assert ( + transport[0].get.call_args_list[1].kwargs["headers"]["Authorization"] + == "Bearer second-token" + ) + + +def test_explicit_default_https_port_is_allowed(endpoints): + endpoints["executeTurn"] = endpoints["executeTurn"].replace(HOST, HOST + ":443") + urls = ConversationEndpoints.from_response(endpoints, DISCOVERY_URL) + assert ":443/" in urls.get_url("executeTurn", "id") + + +@pytest.mark.asyncio +async def test_transport_failure_is_retryable(settings, endpoints, transport): + client = CopilotClient(settings, "token") + transport[0].get.side_effect = aiohttp.ClientConnectionError("connection failed") + with pytest.raises(aiohttp.ClientConnectionError, match="connection failed"): + await client._resolve_connection_url() + assert settings.direct_connect_url is None + + @asynccontextmanager + async def get(*args, **kwargs): + yield transport[1] + + transport[0].get.side_effect = get + assert await client._resolve_connection_url() == endpoints["createConversation"] diff --git a/tests/copilotstudio_client/test_copilot_client.py b/tests/copilotstudio_client/test_copilot_client.py index cffc82f62..fa034144f 100644 --- a/tests/copilotstudio_client/test_copilot_client.py +++ b/tests/copilotstudio_client/test_copilot_client.py @@ -5,6 +5,7 @@ from microsoft_agents.activity import Activity from microsoft_agents.copilotstudio.client import ( + AgentType, ConnectionSettings, CopilotClient, PowerPlatformEnvironment, @@ -23,6 +24,7 @@ async def test_copilot_client_error(mocker): connection_settings = ConnectionSettings( "environment-id", "agent-id", + direct_connect_url="https://api.copilotstudio.com/bot", client_session_settings={"base_url": "https://api.copilotstudio.com"}, ) @@ -54,6 +56,7 @@ async def test_copilot_client_basic(mocker): connection_settings = ConnectionSettings( "environment-id", "agent-id", + direct_connect_url="https://api.copilotstudio.com/bot", client_session_settings={"base_url": "https://api.copilotstudio.com"}, ) @@ -101,6 +104,7 @@ async def test_copilot_client_start_with_request(mocker): connection_settings = ConnectionSettings( "environment-id", "agent-id", + direct_connect_url="https://api.copilotstudio.com/bot", client_session_settings={"base_url": "https://api.copilotstudio.com"}, ) @@ -153,6 +157,7 @@ async def test_copilot_client_send_activity(mocker): connection_settings = ConnectionSettings( "environment-id", "agent-id", + direct_connect_url="https://api.copilotstudio.com/bot", client_session_settings={"base_url": "https://api.copilotstudio.com"}, ) @@ -202,6 +207,7 @@ async def test_copilot_client_execute(mocker): connection_settings = ConnectionSettings( "environment-id", "agent-id", + direct_connect_url="https://api.copilotstudio.com/bot", client_session_settings={"base_url": "https://api.copilotstudio.com"}, ) @@ -252,6 +258,7 @@ async def test_copilot_client_subscribe(mocker): connection_settings = ConnectionSettings( "environment-id", "agent-id", + direct_connect_url="https://api.copilotstudio.com/bot", client_session_settings={"base_url": "https://api.copilotstudio.com"}, ) @@ -513,6 +520,7 @@ async def test_enable_diagnostics_logging(mocker, caplog): connection_settings = ConnectionSettings( "environment-id", "agent-id", + direct_connect_url="https://api.copilotstudio.com/bot", client_session_settings={"base_url": "https://api.copilotstudio.com"}, enable_diagnostics=True, ) @@ -570,6 +578,7 @@ async def test_experimental_endpoint_capture(mocker): connection_settings = ConnectionSettings( "environment-id", "agent-id", + copilot_agent_type=AgentType.PREBUILT, client_session_settings={"base_url": "https://api.copilotstudio.com"}, use_experimental_endpoint=True, ) @@ -630,6 +639,7 @@ async def test_experimental_endpoint_not_captured_when_disabled(mocker): connection_settings = ConnectionSettings( "environment-id", "agent-id", + copilot_agent_type=AgentType.PREBUILT, client_session_settings={"base_url": "https://api.copilotstudio.com"}, use_experimental_endpoint=False, )