Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2c7d9c8
Another commit
rodrigobr-msft Aug 26, 2026
13bf201
Update InputFile and AttachmentDownloader definitions
rodrigobr-msft Aug 26, 2026
c593f0e
Utilizing kwarg name
rodrigobr-msft Aug 26, 2026
23d030e
Initial of M365AttachmentDownloader implementation
rodrigobr-msft Aug 26, 2026
7da440f
AttachmentDownloader implementation
rodrigobr-msft Sep 9, 2026
d1bb59f
M365AttachmentDownloader implementation
rodrigobr-msft Sep 9, 2026
59e9fe4
Another commit
rodrigobr-msft Sep 9, 2026
9388c0b
Adding AttachmentsBase.get_attachment_uri method
rodrigobr-msft Sep 9, 2026
9e1c66e
Adding AttachmentsBase.get_attachment_uri method
rodrigobr-msft Sep 10, 2026
af08cec
Sample and formatting
rodrigobr-msft Sep 10, 2026
8e23974
Tweaks to image loading
rodrigobr-msft Sep 10, 2026
e5e6db2
Merge branch 'main' of https://github.com/microsoft/Agents-for-python…
rodrigobr-msft Sep 10, 2026
259db75
Adding changelog entry
rodrigobr-msft Sep 10, 2026
f61ea88
Adding another changelog entry
rodrigobr-msft Sep 10, 2026
181ecac
Another commit
rodrigobr-msft Sep 25, 2026
eb9c12a
Addressing merge conflcit
rodrigobr-msft Sep 25, 2026
d74722d
Another commit
rodrigobr-msft Sep 25, 2026
f2d8bd1
Addressing usage feedback and potential issue
rodrigobr-msft Sep 25, 2026
803ad50
Updating changelog
rodrigobr-msft Sep 25, 2026
20aee07
Merge branch 'main' of https://github.com/microsoft/Agents-for-python…
rodrigobr-msft Sep 25, 2026
7073ccb
Addressing PR feedback
rodrigobr-msft Sep 25, 2026
68b605d
Fix to agent logic
rodrigobr-msft Sep 29, 2026
2659ce4
Updating merge conflict in changelog.md
rodrigobr-msft Sep 29, 2026
70983e6
Merge branch 'main' into users/robrandao/downloader
rodrigobr-msft Sep 30, 2026
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
6 changes: 5 additions & 1 deletion changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,19 @@

## Major Features & Enhancements

- **Attachment Downloaders**: Added `AttachmentDownloader` and `M365AttachmentDownloader` for downloading standard, Microsoft Teams, and Microsoft 365 Copilot attachments into `TurnState.temp.input_files`.
- **Teams API Dependency**: Updated `microsoft-agents-hosting-msteams` to use `microsoft-teams-api` 2.1.0

## Samples

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

## Bug Fixes

- **Client Citation Deserialization**: Fixed deserialization of citations containing `@id` values

---


# 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