Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
15 changes: 15 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
# Microsoft 365 Agents SDK for Python - Release Notes v1.8.0 (Unreleased)

**Release Date:** Unreleased
**Previous Version:** 1.7.0 (Released 2026-09-17)

## Major Features & Enhancements

- **Attachment Downloaders**: Added `AttachmentDownloader` and `M365AttachmentDownloader` for downloading standard, Microsoft Teams, and Microsoft 365 Copilot attachments into `TurnState.temp.input_files`.

## Samples

- **Handling Attachments Sample**: Added a sample demonstrating incoming attachment downloads, inline and internet-hosted attachments, and Teams attachment uploads.

---

# Microsoft 365 Agents SDK for Python - Release Notes v1.7.0

**Release Date:** 2026-09-17
Expand Down
Empty file.
46 changes: 46 additions & 0 deletions dev/integration/tests/downloader/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

from dataclasses import dataclass, field

import pytest
from aiohttp import web
from aiohttp.test_utils import TestServer


@dataclass
class DownloadServer:
server: TestServer
requested_paths: list[str] = field(default_factory=list)

def url(self, path: str) -> str:
return str(self.server.make_url(path))


@pytest.fixture
async def download_server():
requested_paths: list[str] = []

async def download(request: web.Request) -> web.Response:
requested_paths.append(request.path)

if request.match_info["filename"] == "missing.txt":
raise web.HTTPNotFound()
if request.match_info["filename"] == "photo.jpg":
return web.Response(body=b"image-content", content_type="image/jpeg")
if request.match_info["filename"] == "empty-content-type.txt":
return web.Response(
body=b"untyped-content",
headers={"Content-Type": ""},
)
return web.Response(body=b"document-content", content_type="text/plain")

app = web.Application()
app.router.add_get("/files/{filename}", download)
server = TestServer(app, host="localhost")
await server.start_server()

try:
yield DownloadServer(server=server, requested_paths=requested_paths)
finally:
await server.close()
69 changes: 69 additions & 0 deletions dev/integration/tests/downloader/scenario.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

import json

from microsoft_agents.activity import Activity, ActivityTypes, Attachment
from microsoft_agents.hosting.core import TurnContext, TurnState
from microsoft_agents.hosting.core.app.input_file import InputFileDownloader
from microsoft_agents.hosting.testing import (
AgentClient,
AgentEnvironment,
AiohttpScenario,
)


def _configure_agent(env: AgentEnvironment) -> None:
@env.agent_application.message("Download the attached files.")
async def report_downloaded_files(context: TurnContext, state: TurnState) -> None:
files = [
{
"content": file.content.decode("utf-8"),
"content_type": file.content_type,
"content_url": file.content_url,
"filename": file.filename,
}
for file in state.temp.input_files
]
await context.send_activity(json.dumps(files))


DOWNLOADER_SCENARIO = AiohttpScenario.create(
_configure_agent,
use_jwt_middleware=False,
)


async def download_attachments(
agent_client: AgentClient,
agent_environment: AgentEnvironment,
downloaders: InputFileDownloader | list[InputFileDownloader],
*,
channel_id: str,
attachments: list[Attachment],
) -> list[dict]:
if isinstance(downloaders, InputFileDownloader):
downloaders = [downloaders]
agent_environment.agent_application.options.file_downloaders = downloaders
activity = Activity(
type="message",
text="Download the attached files.",
channel_id=channel_id,
attachments=attachments,
)

exchanges = await agent_client.ex_send_expect_replies(activity)

assert len(exchanges) == 1
assert exchanges[0].error is None
replies = [
response
for response in exchanges[0].responses
if response.type == ActivityTypes.message
]
assert len(replies) == 1, (
f"Expected one agent reply, got {len(replies)} "
f"(status={exchanges[0].status_code}, body={exchanges[0].body!r})"
)
assert replies[0].text is not None
return json.loads(replies[0].text)
259 changes: 259 additions & 0 deletions dev/integration/tests/downloader/test_attachment_downloader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

import json

import pytest

from microsoft_agents.activity import Attachment, Channels
from microsoft_agents.hosting.core import OutboundHostValidator
from microsoft_agents.hosting.core.app.attachment_downloader import (
AttachmentDownloader,
)
from microsoft_agents.hosting.testing import AgentClient, AgentEnvironment

from .conftest import DownloadServer
from .scenario import DOWNLOADER_SCENARIO, download_attachments


@pytest.mark.agent_test(DOWNLOADER_SCENARIO)
class TestAttachmentDownloader:
async def test_downloads_file_from_local_http_server(
self,
agent_client: AgentClient,
agent_environment: AgentEnvironment,
download_server: DownloadServer,
):
content_url = download_server.url("/files/document.txt")
attachment = Attachment(
content_type="text/plain",
content_url=content_url,
name="document.txt",
)

files = await download_attachments(
agent_client,
agent_environment,
AttachmentDownloader(),
channel_id=Channels.webchat,
attachments=[attachment],
)

assert files == [
{
"content": "document-content",
"content_type": "text/plain",
"content_url": content_url,
"filename": "document.txt",
}
]
assert download_server.requested_paths == ["/files/document.txt"]

async def test_ignores_remote_file_when_server_returns_not_found(
self,
agent_client: AgentClient,
agent_environment: AgentEnvironment,
download_server: DownloadServer,
):
attachment = Attachment(
content_type="text/plain",
content_url=download_server.url("/files/missing.txt"),
name="missing.txt",
)

files = await download_attachments(
agent_client,
agent_environment,
AttachmentDownloader(),
channel_id=Channels.webchat,
attachments=[attachment],
)

assert files == []
assert download_server.requested_paths == ["/files/missing.txt"]

async def test_leaves_teams_attachments_for_the_m365_downloader(
self,
agent_client: AgentClient,
agent_environment: AgentEnvironment,
download_server: DownloadServer,
):
attachment = Attachment(
content_type="text/plain",
content_url=download_server.url("/files/document.txt"),
name="document.txt",
)

files = await download_attachments(
agent_client,
agent_environment,
AttachmentDownloader(),
channel_id=Channels.ms_teams,
attachments=[attachment],
)

assert files == []
assert download_server.requested_paths == []

async def test_downloads_mixed_attachment_batch_in_original_order(
self,
agent_client: AgentClient,
agent_environment: AgentEnvironment,
download_server: DownloadServer,
):
remote_url = download_server.url("/files/document.txt")
attachments = [
Attachment(
content_type="text/plain",
content_url=remote_url,
name="remote.txt",
),
Attachment(
content_type="text/plain",
content_url=download_server.url("/files/missing.txt"),
name="missing.txt",
),
Attachment(
content_type="application/json",
content={"source": "inline"},
name="inline.json",
),
]

files = await download_attachments(
agent_client,
agent_environment,
AttachmentDownloader(),
channel_id=Channels.webchat,
attachments=attachments,
)

assert files == [
{
"content": "document-content",
"content_type": "text/plain",
"content_url": remote_url,
"filename": "remote.txt",
},
{
"content": json.dumps({"source": "inline"}),
"content_type": "application/json",
"content_url": None,
"filename": "inline.json",
},
]
assert download_server.requested_paths == [
"/files/document.txt",
"/files/missing.txt",
]

async def test_normalizes_downloaded_image_content_type_to_png(
self,
agent_client: AgentClient,
agent_environment: AgentEnvironment,
download_server: DownloadServer,
):
content_url = download_server.url("/files/photo.jpg")

files = await download_attachments(
agent_client,
agent_environment,
AttachmentDownloader(),
channel_id=Channels.webchat,
attachments=[
Attachment(
content_type="image/jpeg",
content_url=content_url,
name="photo.jpg",
)
],
)

assert files[0]["content_type"] == "image/png"
assert download_server.requested_paths == ["/files/photo.jpg"]

async def test_serializes_inline_attachment_content_as_json(
self,
agent_client: AgentClient,
agent_environment: AgentEnvironment,
):
files = await download_attachments(
agent_client,
agent_environment,
AttachmentDownloader(),
channel_id=Channels.webchat,
attachments=[
Attachment(
content_type="application/vnd.example",
content={"message": "hello"},
name="payload.json",
)
],
)

assert files == [
{
"content": json.dumps({"message": "hello"}),
"content_type": "application/vnd.example",
"content_url": None,
"filename": "payload.json",
}
]

async def test_blocks_remote_file_rejected_by_host_validator(
self,
agent_client: AgentClient,
agent_environment: AgentEnvironment,
download_server: DownloadServer,
):
downloader = AttachmentDownloader(
host_validator=OutboundHostValidator(
enabled=True,
hosts=["allowed.example"],
include_default_microsoft_hosts=False,
)
)

files = await download_attachments(
agent_client,
agent_environment,
downloader,
channel_id=Channels.webchat,
attachments=[
Attachment(
content_type="text/plain",
content_url=download_server.url("/files/document.txt"),
name="document.txt",
)
],
)

assert files == []
assert download_server.requested_paths == []

async def test_reports_empty_response_content_type(
self,
agent_client: AgentClient,
agent_environment: AgentEnvironment,
download_server: DownloadServer,
):
files = await download_attachments(
agent_client,
agent_environment,
AttachmentDownloader(),
channel_id=Channels.webchat,
attachments=[
Attachment(
content_type="text/plain",
content_url=download_server.url(
"/files/empty-content-type.txt"
),
name="empty-content-type.txt",
)
],
)

assert files[0]["content_type"] == ""
assert download_server.requested_paths == [
"/files/empty-content-type.txt"
]
Loading
Loading