Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/microbots/bot/CopilotBot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────
Expand Down
274 changes: 274 additions & 0 deletions test/bot/test_copilot_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,6 +30,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()
Expand Down Expand Up @@ -72,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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -246,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:
Expand Down Expand Up @@ -1324,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
# ---------------------------------------------------------------------------
Expand Down
Loading