From a495d9432255c591636630a8f748b7067d3dc0b0 Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Thu, 23 Jul 2026 11:14:09 -0700 Subject: [PATCH 1/3] Python: Forward GitHub Copilot input attachments as inline blobs The Python GitHubCopilotAgent built the prompt from message text only, so DataContent (images/documents) passed on input was silently dropped. The .NET provider already forwards these as attachments. Map input data content to the Copilot SDK's inline BlobAttachment (base64, no temp files) in both the streaming and non-streaming send paths. Data content without a media type is dropped with a warning instead of silently. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ac85c429-4115-42ef-a18a-f576e3cf03f7 --- .../agent_framework_github_copilot/_agent.py | 54 ++++++++++- .../tests/test_github_copilot_agent.py | 90 ++++++++++++++++++- 2 files changed, 140 insertions(+), 4 deletions(-) diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 9256e3eb11..b297616755 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -30,7 +30,11 @@ ) from agent_framework._settings import load_settings from agent_framework._tools import FunctionTool, ToolTypes -from agent_framework._types import AgentRunInputs, normalize_tools +from agent_framework._types import ( + AgentRunInputs, + _get_data_bytes_as_str, # pyright: ignore[reportPrivateUsage] + normalize_tools, +) from agent_framework.exceptions import AgentException from agent_framework.observability import AgentTelemetryLayer @@ -47,6 +51,8 @@ from copilot import CopilotClient, CopilotSession, RuntimeConnection from copilot.generated.rpc import PermissionDecisionUserNotAvailable from copilot.session import ( + Attachment, + BlobAttachment, MCPServerConfig, PermissionRequestResult, PreToolUseHandler, @@ -656,10 +662,11 @@ def usage_event_handler(event: SessionEvent) -> None: prompt = "\n".join([message.text for message in context_messages]) if session_context.instructions: prompt = "\n".join(session_context.instructions) + "\n" + prompt + attachments = self._prepare_attachments_for_copilot(context_messages) unsubscribe = copilot_session.on(usage_event_handler) try: - response_event = await copilot_session.send_and_wait(prompt, timeout=timeout) + response_event = await copilot_session.send_and_wait(prompt, attachments=attachments, timeout=timeout) except Exception as ex: raise AgentException(f"GitHub Copilot request failed: {ex}") from ex finally: @@ -762,6 +769,7 @@ async def _stream_updates( prompt = "\n".join([message.text for message in context_messages]) if session_context.instructions: prompt = "\n".join(session_context.instructions) + "\n" + prompt + attachments = self._prepare_attachments_for_copilot(context_messages) queue: asyncio.Queue[AgentResponseUpdate | Exception | None] = asyncio.Queue() @@ -844,7 +852,7 @@ def event_handler(event: SessionEvent) -> None: unsubscribe = copilot_session.on(event_handler) try: - await copilot_session.send(prompt) + await copilot_session.send(prompt, attachments=attachments) while (item := await queue.get()) is not None: if isinstance(item, Exception): @@ -916,6 +924,46 @@ def _prepare_system_message( elif opts_system_message is not None: opts["system_message"] = opts_system_message + @staticmethod + def _prepare_attachments_for_copilot(messages: Sequence[Message]) -> list[Attachment] | None: + """Convert inline binary message content into Copilot SDK attachments. + + Scans the outgoing messages for ``data`` content (binary payloads such as + images or documents carried as base64 data URIs) and maps each one to an + inline ``blob`` attachment understood by the Copilot SDK. This mirrors the + .NET provider, which forwards ``DataContent`` as attachments. Remote URI + content (for example ``https://`` links) is left in the prompt text and not + attached, matching the SDK's supported attachment shapes. + + Args: + messages: The messages being sent to the Copilot session. + + Returns: + A list of Copilot ``Attachment`` objects, or ``None`` when the messages + contain no attachable binary content. + """ + attachments: list[Attachment] = [] + for message in messages: + for content in message.contents: + if content.type != "data": + continue + data_str = _get_data_bytes_as_str(content) + if not data_str: + continue + if not content.media_type: + logger.warning( + "Dropping GitHub Copilot attachment with no media type; the Copilot SDK " + "requires a MIME type for inline binary content." + ) + continue + blob: BlobAttachment = { + "type": "blob", + "data": data_str, + "mimeType": content.media_type, + } + attachments.append(blob) + return attachments or None + def _prepare_tools( self, tools: Sequence[ToolTypes | CopilotTool], diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index 9d5a113dd7..a9e2a72578 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -2,6 +2,7 @@ # ruff: noqa: E402 +import base64 import inspect import os import unittest.mock @@ -3351,10 +3352,97 @@ def load_skill(skill_name: str) -> str: assert "load_skill" in tool_names +class TestGitHubCopilotAttachments: + """Tests for forwarding inline binary message content as Copilot attachments.""" + + async def test_data_content_forwarded_as_blob_attachment( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + ) -> None: + """Non-streaming: DataContent is sent to the SDK as an inline blob attachment.""" + mock_session.send_and_wait.return_value = assistant_message_event + image_bytes = b"\x89PNG\r\n\x1a\n-fake-image" + message = Message( + role="user", + contents=[ + Content.from_text("Describe this image"), + Content.from_data(data=image_bytes, media_type="image/png"), + ], + ) + + agent = GitHubCopilotAgent(client=mock_client) + await agent.run(message) + + attachments = mock_session.send_and_wait.call_args.kwargs["attachments"] + assert attachments is not None + assert len(attachments) == 1 + assert attachments[0]["type"] == "blob" + assert attachments[0]["mimeType"] == "image/png" + assert base64.b64decode(attachments[0]["data"]) == image_bytes + + async def test_data_content_forwarded_as_blob_attachment_streaming( + self, + mock_client: MagicMock, + mock_session: MagicMock, + session_idle_event: SessionEvent, + ) -> None: + """Streaming: DataContent is sent to the SDK as an inline blob attachment.""" + + def mock_on(handler: Any) -> Any: + handler(session_idle_event) + return lambda: None + + mock_session.on = mock_on + image_bytes = b"\x89PNG\r\n\x1a\n-fake-image" + message = Message( + role="user", + contents=[ + Content.from_text("Describe this image"), + Content.from_data(data=image_bytes, media_type="image/png"), + ], + ) + + agent = GitHubCopilotAgent(client=mock_client) + async for _ in agent.run(message, stream=True): + pass + + attachments = mock_session.send.call_args.kwargs["attachments"] + assert attachments is not None + assert len(attachments) == 1 + assert attachments[0]["type"] == "blob" + assert attachments[0]["mimeType"] == "image/png" + assert base64.b64decode(attachments[0]["data"]) == image_bytes + + async def test_text_only_message_sends_no_attachments( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + ) -> None: + """A text-only message results in no attachments being forwarded.""" + mock_session.send_and_wait.return_value = assistant_message_event + + agent = GitHubCopilotAgent(client=mock_client) + await agent.run("Just text, no attachments") + + assert mock_session.send_and_wait.call_args.kwargs["attachments"] is None + + def test_prepare_attachments_skips_data_without_media_type(self) -> None: + """Data content lacking a media type is dropped rather than sent without a MIME type.""" + content = Content.from_data(data=b"payload", media_type="application/octet-stream") + content.media_type = None + message = Message(role="user", contents=[content]) + + attachments = GitHubCopilotAgent._prepare_attachments_for_copilot([message]) + + assert attachments is None + + # --------------------------------------------------------------------------- # Integration tests — require COPILOT_GITHUB_TOKEN env var # --------------------------------------------------------------------------- - skip_if_copilot_integration_tests_disabled = pytest.mark.skipif( os.getenv("COPILOT_GITHUB_TOKEN", "") == "", reason="No COPILOT_GITHUB_TOKEN provided; skipping integration tests.", From 0aad9900f203075f4a3e644b937d17469559c9a8 Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Thu, 23 Jul 2026 11:30:13 -0700 Subject: [PATCH 2/3] Python: Handle non-base64 data URIs and fix attachment docstring Address PR review feedback: - Guard _get_data_bytes_as_str against ContentError so a non-base64 data: URI (which _validate_uri still classifies as type="data") is skipped with a warning instead of failing the entire Copilot request. - Correct the docstring: remote URIs and non-base64 data URIs are neither attached nor added to the prompt (the prompt is built from text content only). - Add tests for the non-base64 data URI path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ac85c429-4115-42ef-a18a-f576e3cf03f7 --- .../agent_framework_github_copilot/_agent.py | 22 +++++++++++---- .../tests/test_github_copilot_agent.py | 28 +++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index b297616755..1a78fb185f 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -35,7 +35,7 @@ _get_data_bytes_as_str, # pyright: ignore[reportPrivateUsage] normalize_tools, ) -from agent_framework.exceptions import AgentException +from agent_framework.exceptions import AgentException, ContentError from agent_framework.observability import AgentTelemetryLayer if sys.version_info >= (3, 11): @@ -930,10 +930,13 @@ def _prepare_attachments_for_copilot(messages: Sequence[Message]) -> list[Attach Scans the outgoing messages for ``data`` content (binary payloads such as images or documents carried as base64 data URIs) and maps each one to an - inline ``blob`` attachment understood by the Copilot SDK. This mirrors the - .NET provider, which forwards ``DataContent`` as attachments. Remote URI - content (for example ``https://`` links) is left in the prompt text and not - attached, matching the SDK's supported attachment shapes. + inline ``blob`` attachment understood by the Copilot SDK. + + Only base64 ``data:`` content is forwarded as an attachment. Other content + is not turned into an attachment: text content is already carried in the + prompt, while remote URIs (for example ``https://`` links) and malformed or + non-base64 ``data:`` URIs are skipped -- they are neither attached nor added + to the prompt. Args: messages: The messages being sent to the Copilot session. @@ -947,7 +950,14 @@ def _prepare_attachments_for_copilot(messages: Sequence[Message]) -> list[Attach for content in message.contents: if content.type != "data": continue - data_str = _get_data_bytes_as_str(content) + try: + data_str = _get_data_bytes_as_str(content) + except ContentError: + logger.warning( + "Skipping GitHub Copilot attachment with an unsupported data URI; " + "only base64-encoded 'data:' URIs can be forwarded as attachments." + ) + continue if not data_str: continue if not content.media_type: diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index a9e2a72578..7c59ee7aa8 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -3439,6 +3439,34 @@ def test_prepare_attachments_skips_data_without_media_type(self) -> None: assert attachments is None + async def test_non_base64_data_uri_is_skipped_not_raised( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + ) -> None: + """A non-base64 ``data:`` URI is skipped instead of failing the whole request.""" + mock_session.send_and_wait.return_value = assistant_message_event + # ``Content.from_uri`` classifies this as type="data" but it is not base64-encoded, + # so extracting its bytes raises ContentError internally. + non_base64 = Content.from_uri("data:text/plain,hello") + assert non_base64.type == "data" + message = Message(role="user", contents=[Content.from_text("hi"), non_base64]) + + agent = GitHubCopilotAgent(client=mock_client) + # Should complete without raising. + await agent.run(message) + + assert mock_session.send_and_wait.call_args.kwargs["attachments"] is None + + def test_prepare_attachments_skips_non_base64_data_uri(self) -> None: + """The helper drops a non-base64 ``data:`` URI rather than raising ContentError.""" + message = Message(role="user", contents=[Content.from_uri("data:text/plain,hello")]) + + attachments = GitHubCopilotAgent._prepare_attachments_for_copilot([message]) + + assert attachments is None + # --------------------------------------------------------------------------- # Integration tests — require COPILOT_GITHUB_TOKEN env var From 08183a27e2774cb65b37a35f9896daa5a8fba6bf Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Thu, 23 Jul 2026 11:43:14 -0700 Subject: [PATCH 3/3] Python: Fix flaky attachment test under telemetry The end-to-end non-base64 data URI test failed in CI because GitHubCopilotAgent's telemetry layer serializes message content (observability._to_otel_part -> _get_data_bytes_as_str), which raises ContentError on a non-base64 data: URI before the attachment code runs. That is an unrelated core-observability limitation, not attachment behavior. Use RawGitHubCopilotAgent (no telemetry layer) for that test so it isolates the provider's send path. The direct helper test still covers the ContentError guard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ac85c429-4115-42ef-a18a-f576e3cf03f7 --- .../tests/test_github_copilot_agent.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index 7c59ee7aa8..559180856f 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -38,7 +38,7 @@ ) from copilot.tools import ToolInvocation, ToolResult -from agent_framework_github_copilot import GitHubCopilotAgent, GitHubCopilotOptions +from agent_framework_github_copilot import GitHubCopilotAgent, GitHubCopilotOptions, RawGitHubCopilotAgent def copilot_options(options: GitHubCopilotOptions) -> GitHubCopilotOptions: @@ -3445,7 +3445,13 @@ async def test_non_base64_data_uri_is_skipped_not_raised( mock_session: MagicMock, assistant_message_event: SessionEvent, ) -> None: - """A non-base64 ``data:`` URI is skipped instead of failing the whole request.""" + """A non-base64 ``data:`` URI is skipped by the send path instead of failing the request. + + Uses ``RawGitHubCopilotAgent`` (no telemetry layer) to isolate the provider's own + attachment handling. The telemetry layer in ``GitHubCopilotAgent`` independently + serializes message content and would trip a separate core limitation on this + contrived input, which is unrelated to attachment forwarding. + """ mock_session.send_and_wait.return_value = assistant_message_event # ``Content.from_uri`` classifies this as type="data" but it is not base64-encoded, # so extracting its bytes raises ContentError internally. @@ -3453,7 +3459,7 @@ async def test_non_base64_data_uri_is_skipped_not_raised( assert non_base64.type == "data" message = Message(role="user", contents=[Content.from_text("hi"), non_base64]) - agent = GitHubCopilotAgent(client=mock_client) + agent = RawGitHubCopilotAgent(client=mock_client) # Should complete without raising. await agent.run(message)