From 8a3480afd4202862163aa6c936cdbbfc42872bd5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 May 2026 11:09:23 +0000 Subject: [PATCH 1/2] Fix PermissionHandler import path for github-copilot-sdk 0.3.0 Agent-Logs-Url: https://github.com/microsoft/microbots/sessions/eb3e9f0c-2ac7-4c6e-a157-bc1795862612 Co-authored-by: 0xba1a <2942888+0xba1a@users.noreply.github.com> --- src/microbots/bot/CopilotBot.py | 18 +++++++++++++++++- test/bot/test_copilot_bot.py | 2 ++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/microbots/bot/CopilotBot.py b/src/microbots/bot/CopilotBot.py index bb8e656..1c97365 100644 --- a/src/microbots/bot/CopilotBot.py +++ b/src/microbots/bot/CopilotBot.py @@ -304,13 +304,29 @@ def __init__( ): try: from copilot import CopilotClient, ExternalServerConfig - from copilot.types import PermissionHandler except ImportError: raise ImportError( "CopilotBot requires the github-copilot-sdk package. " "Install with: pip install microbots[ghcp]" ) + # ``PermissionHandler`` lives in ``copilot.session`` in + # github-copilot-sdk >= 0.3.0 and in ``copilot.types`` in older + # releases. Try the new location first and fall back to the old + # one so we work with both. + try: + from copilot.session import PermissionHandler + except ImportError: + try: + from copilot.types import PermissionHandler + except ImportError as exc: + raise ImportError( + "CopilotBot could not locate 'PermissionHandler' in the " + "installed github-copilot-sdk (looked in copilot.session " + "and copilot.types). Please install a compatible version, " + "e.g. pip install -U microbots[ghcp]." + ) from exc + self.additional_tools = additional_tools or [] # ── Resolve auth: BYOK vs native GitHub Copilot ───────────── diff --git a/test/bot/test_copilot_bot.py b/test/bot/test_copilot_bot.py index 5804d4c..2c6260b 100644 --- a/test/bot/test_copilot_bot.py +++ b/test/bot/test_copilot_bot.py @@ -28,6 +28,8 @@ _mock_session = MagicMock() _mock_session.PermissionRequestResult = MagicMock +_mock_session.PermissionHandler = MagicMock() +_mock_session.PermissionHandler.approve_all = MagicMock() _mock_events = MagicMock() _mock_events.SessionEventType = MagicMock() From d4a23d8a4df8d1a081b60dbcb3cb24f50a500164 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 May 2026 11:18:48 +0000 Subject: [PATCH 2/2] Add unit + integration tests for PermissionHandler SDK compatibility (100% coverage) Agent-Logs-Url: https://github.com/microsoft/microbots/sessions/a7e5c16c-5821-4022-aa26-d04de2059e59 Co-authored-by: 0xba1a <2942888+0xba1a@users.noreply.github.com> --- test/bot/test_copilot_bot.py | 272 +++++++++++++++++++++++++++++++++++ 1 file changed, 272 insertions(+) diff --git a/test/bot/test_copilot_bot.py b/test/bot/test_copilot_bot.py index 2c6260b..5177ad5 100644 --- a/test/bot/test_copilot_bot.py +++ b/test/bot/test_copilot_bot.py @@ -6,11 +6,13 @@ require a real Docker daemon, copilot-cli, and GitHub authentication. """ +import contextlib import importlib import os import shutil import subprocess import sys +import types from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest @@ -74,6 +76,73 @@ def _restore_real_copilot_modules(): del sys.modules["microbots.bot.CopilotBot"] +def _reinstall_default_copilot_mocks(): + """Reinstall the module-level copilot mocks and reload CopilotBot. + + Used to undo a temporary swap performed by ``_swap_copilot_sdk_layout`` + so subsequent tests in this file see the original mock fixtures. + """ + sys.modules["copilot"] = _mock_copilot + sys.modules["copilot.session"] = _mock_session + sys.modules["copilot.generated.session_events"] = _mock_events + sys.modules["copilot.tools"] = _mock_tools + sys.modules["copilot.types"] = _mock_types + if "microbots.bot.CopilotBot" in sys.modules: + importlib.reload(sys.modules["microbots.bot.CopilotBot"]) + + +@contextlib.contextmanager +def _swap_copilot_sdk_layout(*, session_has_handler: bool, types_has_handler: bool): + """Temporarily install fresh stub modules for ``copilot.session`` and + ``copilot.types`` to simulate a specific SDK layout, then reload + ``microbots.bot.CopilotBot`` so the new import logic re-runs. + + ``session_has_handler``/``types_has_handler`` control whether + ``PermissionHandler`` is exposed on the corresponding module. The + returned tuple yields the two ``PermissionHandler`` sentinels (or + ``None`` when not present) so tests can assert which one was picked. + """ + # Build a stub ``copilot`` package that still exposes CopilotClient and + # ExternalServerConfig so the first try/except in __init__ succeeds. + stub_copilot = types.ModuleType("copilot") + stub_copilot.CopilotClient = _mock_copilot.CopilotClient + stub_copilot.ExternalServerConfig = _mock_copilot.ExternalServerConfig + + stub_session = types.ModuleType("copilot.session") + stub_session.PermissionRequestResult = MagicMock + session_handler = MagicMock(name="session.PermissionHandler") if session_has_handler else None + if session_handler is not None: + session_handler.approve_all = MagicMock(name="session.approve_all") + stub_session.PermissionHandler = session_handler + + stub_types = types.ModuleType("copilot.types") + types_handler = MagicMock(name="types.PermissionHandler") if types_has_handler else None + if types_handler is not None: + types_handler.approve_all = MagicMock(name="types.approve_all") + stub_types.PermissionHandler = types_handler + + saved = { + name: sys.modules.get(name) + for name in ("copilot", "copilot.session", "copilot.types") + } + sys.modules["copilot"] = stub_copilot + sys.modules["copilot.session"] = stub_session + sys.modules["copilot.types"] = stub_types + # Other submodules (generated.session_events, tools) keep the existing + # module-level mocks so CopilotBot's other imports still work. + try: + if "microbots.bot.CopilotBot" in sys.modules: + importlib.reload(sys.modules["microbots.bot.CopilotBot"]) + yield session_handler, types_handler + finally: + for name, mod in saved.items(): + if mod is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = mod + _reinstall_default_copilot_mocks() + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -248,6 +317,91 @@ def test_import_error_without_sdk(self): if "microbots.bot.CopilotBot" in sys.modules: importlib.reload(sys.modules["microbots.bot.CopilotBot"]) + # ────────────────────────────────────────────────────────────────── + # PermissionHandler import path — covers the new logic in __init__ + # that supports both pre-0.3.0 and >=0.3.0 github-copilot-sdk layouts. + # ────────────────────────────────────────────────────────────────── + + def _build_bot_under_swap(self, mock_environment, mock_copilot_client): + """Construct a CopilotBot using the reloaded module under the + currently-active ``_swap_copilot_sdk_layout`` context.""" + from microbots.bot.CopilotBot import CopilotBot + with ( + patch("microbots.bot.CopilotBot.LocalDockerEnvironment", return_value=mock_environment), + patch("microbots.bot.CopilotBot.get_free_port", side_effect=[9000]), + patch("microbots.bot.CopilotBot.CopilotBot._install_copilot_cli"), + patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), + patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), + patch("copilot.CopilotClient", return_value=mock_copilot_client), + patch("copilot.ExternalServerConfig", return_value=MagicMock()), + ): + bot = CopilotBot( + model="gpt-4.1", + environment=mock_environment, + github_token="ghp_test", + ) + return bot + + def test_permission_handler_loaded_from_session( + self, mock_environment, mock_copilot_client + ): + """SDK >= 0.3.0: ``PermissionHandler`` is loaded from copilot.session + and is preferred over copilot.types when both expose it.""" + with _swap_copilot_sdk_layout( + session_has_handler=True, types_has_handler=True + ) as (session_handler, types_handler): + bot = self._build_bot_under_swap(mock_environment, mock_copilot_client) + try: + assert bot._PermissionHandler is session_handler + assert bot._PermissionHandler is not types_handler + finally: + bot._loop.call_soon_threadsafe(bot._loop.stop) + bot._thread.join(timeout=2) + bot.environment = None + + def test_permission_handler_falls_back_to_types( + self, mock_environment, mock_copilot_client + ): + """Pre-0.3.0 SDK: ``PermissionHandler`` is absent from copilot.session + and the import falls back to copilot.types.""" + with _swap_copilot_sdk_layout( + session_has_handler=False, types_has_handler=True + ) as (session_handler, types_handler): + assert session_handler is None + bot = self._build_bot_under_swap(mock_environment, mock_copilot_client) + try: + assert bot._PermissionHandler is types_handler + finally: + bot._loop.call_soon_threadsafe(bot._loop.stop) + bot._thread.join(timeout=2) + bot.environment = None + + def test_import_error_when_permission_handler_missing_everywhere( + self, mock_environment, mock_copilot_client + ): + """If neither copilot.session nor copilot.types exposes + ``PermissionHandler`` we raise a distinct, accurate ImportError + (and not the misleading "github-copilot-sdk is not installed" one).""" + with _swap_copilot_sdk_layout( + session_has_handler=False, types_has_handler=False + ): + from microbots.bot.CopilotBot import CopilotBot + with pytest.raises(ImportError, match="PermissionHandler"): + with ( + patch("microbots.bot.CopilotBot.LocalDockerEnvironment", return_value=mock_environment), + patch("microbots.bot.CopilotBot.get_free_port", side_effect=[9000]), + patch("microbots.bot.CopilotBot.CopilotBot._install_copilot_cli"), + patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), + patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), + patch("copilot.CopilotClient", return_value=mock_copilot_client), + patch("copilot.ExternalServerConfig", return_value=MagicMock()), + ): + CopilotBot( + model="gpt-4.1", + environment=mock_environment, + github_token="ghp_test", + ) + @pytest.mark.unit class TestCopilotBotRun: @@ -1326,6 +1480,124 @@ def test_mount_additional_copy_succeeds(self, copilot_bot): copilot_bot._mount_additional(mock_mount) # should not raise +# --------------------------------------------------------------------------- +# Integration tests — SDK compatibility (both pre-0.3.0 and >=0.3.0 layouts) +# +# These exercise the full ``CopilotBot.__init__`` → ``run()`` → assistant- +# response path against simulated SDK module layouts so we can verify *both* +# supported ``github-copilot-sdk`` shapes in a single test run (only one +# version of the real SDK can be installed at a time). External I/O (Docker, +# CLI startup, network) is stubbed out; everything else — the new +# PermissionHandler import branch, the asyncio loop, session creation, +# event handling, result extraction — runs for real. +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestCopilotBotSDKLayoutIntegration: + """Integration-level tests that drive a full ``run()`` against each + supported SDK module layout.""" + + def _run_bot_with_layout(self, *, session_has_handler, types_has_handler): + """Build and run a CopilotBot under a simulated SDK layout. + + Returns ``(result, bot, session_handler, types_handler)`` where + ``result`` is the ``BotRunResult`` from ``bot.run()``. + """ + # Mock Docker environment + env = MagicMock() + env.port = 9000 + env.container_port = 8080 + env.container = MagicMock() + env.container.id = "abc123" + env.image = "image:latest" + env.working_dir = "/tmp/work" + env.folder_to_mount = None + env.overlay_mount = False + ok = MagicMock() + ok.return_code = 0 + ok.stdout = "copilot version 1.0.0" + ok.stderr = "" + env.execute = MagicMock(return_value=ok) + env.copy_to_container = MagicMock(return_value=True) + env.stop = MagicMock() + env.get_ipv4_address = MagicMock(return_value="172.17.0.2") + + # Mock SDK session + client + session = AsyncMock() + session.disconnect = AsyncMock() + response = Mock() + response.data = Mock() + response.data.content = "Task completed successfully." + session.send_and_wait = AsyncMock(return_value=response) + session.on = MagicMock() + client = AsyncMock() + client.start = AsyncMock() + client.stop = AsyncMock() + client.create_session = AsyncMock(return_value=session) + + with _swap_copilot_sdk_layout( + session_has_handler=session_has_handler, + types_has_handler=types_has_handler, + ) as (session_handler, types_handler): + from microbots.bot.CopilotBot import CopilotBot + with ( + patch("microbots.bot.CopilotBot.LocalDockerEnvironment", return_value=env), + patch("microbots.bot.CopilotBot.get_free_port", side_effect=[9000]), + patch("microbots.bot.CopilotBot.CopilotBot._install_copilot_cli"), + patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), + patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), + patch("copilot.CopilotClient", return_value=client), + patch("copilot.ExternalServerConfig", return_value=MagicMock()), + ): + bot = CopilotBot( + model="gpt-4.1", + environment=env, + github_token="ghp_test", + ) + try: + result = bot.run("Do the thing") + finally: + # Tear down the asyncio loop thread cleanly + bot._loop.call_soon_threadsafe(bot._loop.stop) + bot._thread.join(timeout=2) + bot.environment = None + return result, bot, session_handler, types_handler, client + + def test_run_with_new_sdk_layout_session(self): + """SDK >= 0.3.0: PermissionHandler lives in ``copilot.session``. + + Verifies CopilotBot initialises, picks up the handler from + ``copilot.session``, and ``run()`` completes successfully passing + that handler as ``on_permission_request``.""" + result, bot, session_handler, types_handler, client = self._run_bot_with_layout( + session_has_handler=True, types_has_handler=False + ) + assert types_handler is None + assert bot._PermissionHandler is session_handler + assert result.status is True, f"run() failed: {result.error}" + assert result.error is None + # The new-SDK handler must be the one wired into the session. + kwargs = client.create_session.await_args.kwargs + assert kwargs["on_permission_request"] is session_handler.approve_all + + def test_run_with_old_sdk_layout_types(self): + """Pre-0.3.0 SDK: PermissionHandler lives in ``copilot.types``. + + Verifies CopilotBot falls back to ``copilot.types`` and ``run()`` + still completes successfully wiring that handler through to the + session.""" + result, bot, session_handler, types_handler, client = self._run_bot_with_layout( + session_has_handler=False, types_has_handler=True + ) + assert session_handler is None + assert bot._PermissionHandler is types_handler + assert result.status is True, f"run() failed: {result.error}" + assert result.error is None + kwargs = client.create_session.await_args.kwargs + assert kwargs["on_permission_request"] is types_handler.approve_all + + # --------------------------------------------------------------------------- # Integration tests — require real Docker + copilot-cli + auth # ---------------------------------------------------------------------------