chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from types import SimpleNamespace
|
||||
from typing import TypeVar
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr, ValidationError
|
||||
|
||||
try:
|
||||
import microsoft_agents.copilotstudio.client # type: ignore # noqa: F401
|
||||
|
||||
copilot_installed = True
|
||||
except ImportError:
|
||||
copilot_installed = False
|
||||
|
||||
pytestmark = pytest.mark.skipif(not copilot_installed, reason="`copilotstudio.client` is not installed")
|
||||
|
||||
if copilot_installed:
|
||||
import semantic_kernel.agents.copilot_studio.copilot_studio_agent as csa_mod
|
||||
from semantic_kernel.agents import (
|
||||
CopilotStudioAgent,
|
||||
CopilotStudioAgentSettings,
|
||||
CopilotStudioAgentThread,
|
||||
)
|
||||
from semantic_kernel.agents.copilot_studio.copilot_studio_agent import (
|
||||
_CopilotStudioAgentTokenFactory,
|
||||
)
|
||||
from semantic_kernel.agents.copilot_studio.copilot_studio_agent_settings import (
|
||||
CopilotStudioAgentAuthMode,
|
||||
)
|
||||
from semantic_kernel.contents import AuthorRole, ChatMessageContent
|
||||
from semantic_kernel.exceptions import (
|
||||
AgentInitializationException,
|
||||
AgentThreadInitializationException,
|
||||
)
|
||||
from semantic_kernel.functions.kernel_arguments import KernelArguments
|
||||
from semantic_kernel.kernel import Kernel
|
||||
from semantic_kernel.prompt_template import PromptTemplateConfig
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
@pytest.fixture
|
||||
def aiter():
|
||||
async def _aiter(items: list[T]) -> AsyncIterator[T]:
|
||||
for item in items:
|
||||
yield item
|
||||
|
||||
return _aiter
|
||||
|
||||
@pytest.fixture
|
||||
def DummyConversation():
|
||||
class DummyConversation:
|
||||
def __init__(self, cid: str):
|
||||
self.id = cid
|
||||
|
||||
return DummyConversation
|
||||
|
||||
@pytest.fixture
|
||||
def DummyActivity(DummyConversation):
|
||||
class DummyActivity:
|
||||
def __init__(self, text: str, cid: str = "conv-123"):
|
||||
self.type = "message"
|
||||
self.text_format = "plain"
|
||||
self.text = text
|
||||
self.suggested_actions = None
|
||||
self.conversation = DummyConversation(cid)
|
||||
|
||||
return DummyActivity
|
||||
|
||||
def test_initialize_thread_with_no_client_throws():
|
||||
with pytest.raises(AgentThreadInitializationException, match="CopilotClient cannot be None"):
|
||||
CopilotStudioAgentThread(client=None)
|
||||
|
||||
def test_normalize_messages():
|
||||
client = MagicMock(spec=csa_mod.CopilotClient)
|
||||
agent = CopilotStudioAgent(client=client)
|
||||
|
||||
single_str = "hello"
|
||||
single_chat = ChatMessageContent(role=AuthorRole.USER, content="hola")
|
||||
mixed = [single_str, single_chat]
|
||||
|
||||
assert agent._normalize_messages(None) == []
|
||||
assert agent._normalize_messages(single_str) == ["hello"]
|
||||
assert agent._normalize_messages(single_chat) == ["hola"]
|
||||
assert agent._normalize_messages(mixed) == ["hello", "hola"]
|
||||
|
||||
def test_plugin_warning_emitted_once(caplog):
|
||||
caplog.set_level(logging.WARNING)
|
||||
|
||||
dummy_kernel = Kernel()
|
||||
dummy_kernel.add_plugin(SimpleNamespace(name="MyPlugin"))
|
||||
|
||||
agent = CopilotStudioAgent(client=MagicMock(spec=csa_mod.CopilotClient), kernel=dummy_kernel)
|
||||
|
||||
warn = [rec for rec in caplog.records if rec.levelno == logging.WARNING]
|
||||
assert len(warn) == 1
|
||||
assert "plugins will be ignored" in warn[0].getMessage()
|
||||
|
||||
caplog.clear()
|
||||
_ = agent.model_copy()
|
||||
assert not caplog.records
|
||||
|
||||
async def test_inner_invoke_prompt_and_yield(monkeypatch, aiter, DummyActivity):
|
||||
client = MagicMock(spec=csa_mod.CopilotClient)
|
||||
prompts: dict[str, str] = {}
|
||||
|
||||
async def fake_ask_question(*, question: str, conversation_id: str):
|
||||
prompts["sent"] = question
|
||||
yield DummyActivity("Aye matey!")
|
||||
|
||||
client.ask_question.side_effect = fake_ask_question
|
||||
client.start_conversation = lambda: aiter([DummyActivity("", "conv-123")])
|
||||
|
||||
monkeypatch.setattr(
|
||||
CopilotStudioAgent,
|
||||
"format_instructions",
|
||||
AsyncMock(return_value="Respond like a pirate"),
|
||||
)
|
||||
|
||||
agent = CopilotStudioAgent(client=client)
|
||||
thread = CopilotStudioAgentThread(client=client)
|
||||
|
||||
replies = [
|
||||
msg
|
||||
async for msg in agent._inner_invoke(
|
||||
thread=thread, messages=["Tell me a joke about bears", "make the joke kid friendly"]
|
||||
)
|
||||
]
|
||||
|
||||
expected_prompt = "Respond like a pirate\nTell me a joke about bears\nmake the joke kid friendly"
|
||||
assert prompts["sent"] == expected_prompt
|
||||
assert len(replies) == 1
|
||||
assert replies[0].content == "Aye matey!"
|
||||
assert replies[0].role is AuthorRole.ASSISTANT
|
||||
|
||||
async def test_get_response(monkeypatch, aiter, DummyActivity):
|
||||
client = MagicMock(spec=csa_mod.CopilotClient)
|
||||
sent: dict[str, str] = {}
|
||||
|
||||
async def fake_ask_question(*, question: str, conversation_id: str):
|
||||
sent["cid"] = conversation_id
|
||||
yield DummyActivity("first", conversation_id)
|
||||
yield DummyActivity("second", conversation_id)
|
||||
|
||||
client.ask_question.side_effect = fake_ask_question
|
||||
client.start_conversation = lambda: aiter([DummyActivity("", "conv-123")])
|
||||
|
||||
monkeypatch.setattr(CopilotStudioAgent, "format_instructions", AsyncMock(return_value=None))
|
||||
|
||||
agent = CopilotStudioAgent(client=client)
|
||||
thread = CopilotStudioAgentThread(client=client)
|
||||
|
||||
item = await agent.get_response(messages="hi there", thread=thread)
|
||||
|
||||
assert item.message.content == "second"
|
||||
assert thread.id == "conv-123"
|
||||
assert sent["cid"] == "conv-123"
|
||||
assert item.thread is thread
|
||||
|
||||
async def test_invoke(monkeypatch, aiter, DummyActivity):
|
||||
client = MagicMock(spec=csa_mod.CopilotClient)
|
||||
sent: dict[str, str] = {}
|
||||
|
||||
async def fake_ask_question(*, question: str, conversation_id: str):
|
||||
sent["cid"] = conversation_id
|
||||
yield DummyActivity("first", conversation_id)
|
||||
yield DummyActivity("second", conversation_id)
|
||||
|
||||
client.ask_question.side_effect = fake_ask_question
|
||||
client.start_conversation = lambda: aiter([DummyActivity("", "conv-123")])
|
||||
|
||||
monkeypatch.setattr(CopilotStudioAgent, "format_instructions", AsyncMock(return_value=None))
|
||||
|
||||
agent = CopilotStudioAgent(
|
||||
client=client,
|
||||
prompt_template_config=PromptTemplateConfig(template="Handle the message in this {{$style}}"),
|
||||
)
|
||||
thread = CopilotStudioAgentThread(client=client)
|
||||
|
||||
responses = []
|
||||
async for response in agent.invoke(
|
||||
messages="hi there", thread=thread, arguments=KernelArguments(style="funny")
|
||||
):
|
||||
responses.append(response)
|
||||
|
||||
item = responses[-1]
|
||||
assert item.message.content == "second"
|
||||
assert thread.id == "conv-123"
|
||||
assert sent["cid"] == "conv-123"
|
||||
assert item.thread is thread
|
||||
|
||||
def test_setup_resources_settings_validation_error():
|
||||
sentinel_exc = ValidationError.from_exception_data("dummy", [], input_type="python")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"semantic_kernel.agents.copilot_studio.copilot_studio_agent.CopilotStudioAgentSettings",
|
||||
side_effect=sentinel_exc,
|
||||
),
|
||||
pytest.raises(AgentInitializationException, match="Failed to create Copilot Studio Agent settings"),
|
||||
):
|
||||
_ = CopilotStudioAgent.create_client(app_client_id="appid", tenant_id="tenantid")
|
||||
|
||||
def test_setup_resources_missing_ids():
|
||||
dummy_settings = MagicMock(spec=CopilotStudioAgentSettings)
|
||||
dummy_settings.app_client_id = None
|
||||
dummy_settings.tenant_id = None
|
||||
|
||||
with (
|
||||
patch(
|
||||
"semantic_kernel.agents.copilot_studio.copilot_studio_agent.CopilotStudioAgentSettings",
|
||||
return_value=dummy_settings,
|
||||
),
|
||||
pytest.raises(
|
||||
AgentInitializationException,
|
||||
match="Missing required configuration field\\(s\\): app_client_id, tenant_id",
|
||||
),
|
||||
):
|
||||
_ = CopilotStudioAgent.create_client()
|
||||
|
||||
def test_setup_resources_happy_path(tmp_path, monkeypatch):
|
||||
dummy_settings = MagicMock(spec=CopilotStudioAgentSettings)
|
||||
dummy_settings.app_client_id = "appid"
|
||||
dummy_settings.tenant_id = "tenantid"
|
||||
dummy_settings.auth_mode = CopilotStudioAgentAuthMode.INTERACTIVE
|
||||
dummy_settings.client_secret = SecretStr("test-secret")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"semantic_kernel.agents.copilot_studio.copilot_studio_agent.CopilotStudioAgentSettings",
|
||||
lambda **_: dummy_settings,
|
||||
)
|
||||
monkeypatch.setattr(_CopilotStudioAgentTokenFactory, "acquire", lambda self: "fake-token")
|
||||
|
||||
sentinel_client = MagicMock(spec=csa_mod.CopilotClient)
|
||||
with patch(
|
||||
"semantic_kernel.agents.copilot_studio.copilot_studio_agent.CopilotClient", return_value=sentinel_client
|
||||
) as mock_client_ctor:
|
||||
cache_path = tmp_path / "cache.bin"
|
||||
monkeypatch.setenv("TOKEN_CACHE_PATH", str(cache_path))
|
||||
|
||||
returned = CopilotStudioAgent.create_client(
|
||||
app_client_id="appid",
|
||||
tenant_id="tenantid",
|
||||
environment_id="env-id",
|
||||
agent_identifier="agent-name",
|
||||
auth_mode=CopilotStudioAgentAuthMode.SERVICE,
|
||||
)
|
||||
|
||||
mock_client_ctor.assert_called_once_with(dummy_settings, "fake-token")
|
||||
assert returned is sentinel_client
|
||||
|
||||
class DummyCache:
|
||||
pass
|
||||
|
||||
class FakeAppSilent:
|
||||
def __init__(self, client_id, authority, token_cache, client_credential=None, **kwargs):
|
||||
pass
|
||||
|
||||
def get_accounts(self):
|
||||
return [{"home_account_id": "acct1"}]
|
||||
|
||||
def acquire_token_silent(self, scopes, account):
|
||||
return {"access_token": "silent-token"}
|
||||
|
||||
def acquire_token_interactive(self, scopes):
|
||||
pytest.skip("Unexpected interactive flow in silent test")
|
||||
|
||||
class FakeAppInteractive:
|
||||
def __init__(self, client_id, authority, token_cache):
|
||||
pass
|
||||
|
||||
def get_accounts(self):
|
||||
return []
|
||||
|
||||
def acquire_token_silent(self, scopes, account):
|
||||
pytest.skip("Unexpected silent flow in interactive test")
|
||||
|
||||
def acquire_token_interactive(self, scopes):
|
||||
return {"access_token": "interactive-token"}
|
||||
|
||||
class FakeAppError:
|
||||
def __init__(self, client_id, authority, token_cache):
|
||||
pass
|
||||
|
||||
def get_accounts(self):
|
||||
return []
|
||||
|
||||
def acquire_token_silent(self, scopes, account):
|
||||
return {}
|
||||
|
||||
def acquire_token_interactive(self, scopes):
|
||||
return {
|
||||
"error": "bad",
|
||||
"error_description": "failed",
|
||||
"correlation_id": "cid",
|
||||
}
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def stub_cache(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
_CopilotStudioAgentTokenFactory,
|
||||
"_get_msal_token_cache",
|
||||
staticmethod(lambda cache_path, fallback_to_plaintext=True: DummyCache()),
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fake_app, expected_token, mode",
|
||||
[
|
||||
pytest.param(
|
||||
FakeAppSilent,
|
||||
"silent-token",
|
||||
CopilotStudioAgentAuthMode.SERVICE,
|
||||
marks=pytest.mark.skip(reason="Skipping SERVICE auth mode test as the mode is not yet supported."),
|
||||
),
|
||||
(FakeAppInteractive, "interactive-token", CopilotStudioAgentAuthMode.INTERACTIVE),
|
||||
],
|
||||
)
|
||||
def test_acquire_token_success(monkeypatch, tmp_path, fake_app, expected_token, mode):
|
||||
settings = CopilotStudioAgentSettings(app_client_id="id", tenant_id="tid")
|
||||
cache_path = str(tmp_path / "cache.bin")
|
||||
|
||||
monkeypatch.setattr(csa_mod, "PublicClientApplication", fake_app)
|
||||
monkeypatch.setattr(csa_mod, "ConfidentialClientApplication", fake_app)
|
||||
|
||||
client_secret = None
|
||||
if fake_app == FakeAppSilent:
|
||||
client_secret = "test-secret"
|
||||
|
||||
factory = _CopilotStudioAgentTokenFactory(
|
||||
settings=settings,
|
||||
cache_path=cache_path,
|
||||
mode=mode,
|
||||
client_secret=client_secret,
|
||||
client_certificate=None,
|
||||
user_assertion=None,
|
||||
)
|
||||
token = factory.acquire()
|
||||
assert token == expected_token
|
||||
|
||||
def test_acquire_token_error(monkeypatch, tmp_path):
|
||||
settings = CopilotStudioAgentSettings(app_client_id="id", tenant_id="tid")
|
||||
cache_path = str(tmp_path / "cache.bin")
|
||||
|
||||
monkeypatch.setattr(csa_mod, "PublicClientApplication", FakeAppError)
|
||||
monkeypatch.setattr(csa_mod, "ConfidentialClientApplication", FakeAppError)
|
||||
|
||||
factory = _CopilotStudioAgentTokenFactory(
|
||||
settings=settings,
|
||||
cache_path=cache_path,
|
||||
mode=CopilotStudioAgentAuthMode.INTERACTIVE,
|
||||
client_secret=None,
|
||||
client_certificate=None,
|
||||
user_assertion=None,
|
||||
)
|
||||
with pytest.raises(AgentInitializationException):
|
||||
factory.acquire()
|
||||
Reference in New Issue
Block a user