From d3652ffa87f16aeee7d706a5bd80c3e841357e15 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 19 Feb 2026 11:31:11 -0800 Subject: [PATCH 01/29] Consolidating StreamingResponse definitions and slight fixes --- .../hosting/aiohttp/app/__init__.py | 14 - .../hosting/aiohttp/app/streaming/__init__.py | 12 - .../hosting/aiohttp/app/streaming/citation.py | 22 - .../aiohttp/app/streaming/citation_util.py | 85 ---- .../app/streaming/streaming_response.py | 416 ------------------ .../core/app/streaming/streaming_response.py | 91 ++-- .../hosting/core/turn_context.py | 11 +- .../hosting/fastapi/app/__init__.py | 14 - .../hosting/fastapi/app/streaming/__init__.py | 12 - .../hosting/fastapi/app/streaming/citation.py | 22 - .../fastapi/app/streaming/citation_util.py | 85 ---- .../app/streaming/streaming_response.py | 392 ----------------- 12 files changed, 43 insertions(+), 1133 deletions(-) delete mode 100644 libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/app/__init__.py delete mode 100644 libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/app/streaming/__init__.py delete mode 100644 libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/app/streaming/citation.py delete mode 100644 libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/app/streaming/citation_util.py delete mode 100644 libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/app/streaming/streaming_response.py delete mode 100644 libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/__init__.py delete mode 100644 libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/__init__.py delete mode 100644 libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/citation.py delete mode 100644 libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/citation_util.py delete mode 100644 libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/streaming_response.py diff --git a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/app/__init__.py b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/app/__init__.py deleted file mode 100644 index 8216be63..00000000 --- a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/app/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .streaming import ( - Citation, - CitationUtil, - StreamingResponse, -) - -__all__ = [ - "Citation", - "CitationUtil", - "StreamingResponse", -] diff --git a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/app/streaming/__init__.py b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/app/streaming/__init__.py deleted file mode 100644 index 4cd61f38..00000000 --- a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/app/streaming/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .citation import Citation -from .citation_util import CitationUtil -from .streaming_response import StreamingResponse - -__all__ = [ - "Citation", - "CitationUtil", - "StreamingResponse", -] diff --git a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/app/streaming/citation.py b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/app/streaming/citation.py deleted file mode 100644 index f643639a..00000000 --- a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/app/streaming/citation.py +++ /dev/null @@ -1,22 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import Optional -from dataclasses import dataclass - - -@dataclass -class Citation: - """Citations returned by the model.""" - - content: str - """The content of the citation.""" - - title: Optional[str] = None - """The title of the citation.""" - - url: Optional[str] = None - """The URL of the citation.""" - - filepath: Optional[str] = None - """The filepath of the document.""" diff --git a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/app/streaming/citation_util.py b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/app/streaming/citation_util.py deleted file mode 100644 index 1ec923dc..00000000 --- a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/app/streaming/citation_util.py +++ /dev/null @@ -1,85 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import re -from typing import List, Optional - -from microsoft_agents.activity import ClientCitation - - -class CitationUtil: - """Utility functions for manipulating text and citations.""" - - @staticmethod - def snippet(text: str, max_length: int) -> str: - """ - Clips the text to a maximum length in case it exceeds the limit. - - Args: - text: The text to clip. - max_length: The maximum length of the text to return, cutting off the last whole word. - - Returns: - The modified text - """ - if len(text) <= max_length: - return text - - snippet = text[:max_length] - snippet = snippet[: min(len(snippet), snippet.rfind(" "))] - snippet += "..." - return snippet - - @staticmethod - def format_citations_response(text: str) -> str: - """ - Convert citation tags `[doc(s)n]` to `[n]` where n is a number. - - Args: - text: The text to format. - - Returns: - The formatted text. - """ - return re.sub(r"\[docs?(\d+)\]", r"[\1]", text, flags=re.IGNORECASE) - - @staticmethod - def get_used_citations( - text: str, citations: List[ClientCitation] - ) -> Optional[List[ClientCitation]]: - """ - Get the citations used in the text. This will remove any citations that are - included in the citations array from the response but not referenced in the text. - - Args: - text: The text to search for citation references, i.e. [1], [2], etc. - citations: The list of citations to search for. - - Returns: - The list of citations used in the text. - """ - regex = re.compile(r"\[(\d+)\]", re.IGNORECASE) - matches = regex.findall(text) - - if not matches: - return None - - # Remove duplicates - filtered_matches = set(matches) - - # Add citations - used_citations = [] - for match in filtered_matches: - citation_ref = f"[{match}]" - found = next( - ( - citation - for citation in citations - if f"[{citation.position}]" == citation_ref - ), - None, - ) - if found: - used_citations.append(found) - - return used_citations if used_citations else None diff --git a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/app/streaming/streaming_response.py b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/app/streaming/streaming_response.py deleted file mode 100644 index 05986cb1..00000000 --- a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/app/streaming/streaming_response.py +++ /dev/null @@ -1,416 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import asyncio -import logging -from typing import List, Optional, Callable, Literal - -from microsoft_agents.activity import ( - Activity, - Entity, - Attachment, - Channels, - ClientCitation, - DeliveryModes, - SensitivityUsageInfo, -) - -from microsoft_agents.hosting.core import error_resources -from microsoft_agents.hosting.core.turn_context import TurnContext - -from .citation import Citation -from .citation_util import CitationUtil - -logger = logging.getLogger(__name__) - - -class StreamingResponse: - """ - A helper class for streaming responses to the client. - - This class is used to send a series of updates to the client in a single response. - The expected sequence of calls is: - - `queue_informative_update()`, `queue_text_chunk()`, `queue_text_chunk()`, ..., `end_stream()`. - - Once `end_stream()` is called, the stream is considered ended and no further updates can be sent. - """ - - def __init__(self, context: "TurnContext"): - """ - Creates a new StreamingResponse instance. - - Args: - context: Context for the current turn of conversation with the user. - """ - self._context = context - self._sequence_number = 1 - self._stream_id: Optional[str] = None - self._message = "" - self._attachments: Optional[List[Attachment]] = None - self._ended = False - self._cancelled = False - - # Queue for outgoing activities - self._queue: List[Callable[[], Activity]] = [] - self._queue_sync: Optional[asyncio.Task] = None - self._chunk_queued = False - - # Powered by AI feature flags - self._enable_feedback_loop = False - self._feedback_loop_type: Optional[Literal["default", "custom"]] = None - self._enable_generated_by_ai_label = False - self._citations: Optional[List[ClientCitation]] = [] - self._sensitivity_label: Optional[SensitivityUsageInfo] = None - - # Channel information - self._is_streaming_channel: bool = False - self._channel_id: Channels = None - self._interval: float = 0.1 # Default interval for sending updates - self._set_defaults(context) - - @property - def stream_id(self) -> Optional[str]: - """ - Gets the stream ID of the current response. - Assigned after the initial update is sent. - """ - return self._stream_id - - @property - def citations(self) -> Optional[List[ClientCitation]]: - """Gets the citations of the current response.""" - return self._citations - - @property - def updates_sent(self) -> int: - """Gets the number of updates sent for the stream.""" - return self._sequence_number - 1 - - def queue_informative_update(self, text: str) -> None: - """ - Queues an informative update to be sent to the client. - - Args: - text: Text of the update to send. - """ - if not self._is_streaming_channel: - return - - if self._ended: - raise RuntimeError(str(error_resources.StreamAlreadyEnded)) - - # Queue a typing activity - def create_activity(): - activity = Activity( - type="typing", - text=text, - entities=[ - Entity( - type="streaminfo", - stream_type="informative", - stream_sequence=self._sequence_number, - ) - ], - ) - self._sequence_number += 1 - return activity - - self._queue_activity(create_activity) - - def queue_text_chunk( - self, text: str, citations: Optional[List[Citation]] = None - ) -> None: - """ - Queues a chunk of partial message text to be sent to the client. - - The text will be sent as quickly as possible to the client. - Chunks may be combined before delivery to the client. - - Args: - text: Partial text of the message to send. - citations: Citations to be included in the message. - """ - if self._cancelled: - return - if self._ended: - raise RuntimeError(str(error_resources.StreamAlreadyEnded)) - - # Update full message text - self._message += text - - # If there are citations, modify the content so that the sources are numbers instead of [doc1], [doc2], etc. - self._message = CitationUtil.format_citations_response(self._message) - - # Queue the next chunk - self._queue_next_chunk() - - async def end_stream(self) -> None: - """ - Ends the stream by sending the final message to the client. - """ - if self._ended: - raise RuntimeError(str(error_resources.StreamAlreadyEnded)) - - # Queue final message - self._ended = True - self._queue_next_chunk() - - # Wait for the queue to drain - await self.wait_for_queue() - - def set_attachments(self, attachments: List[Attachment]) -> None: - """ - Sets the attachments to attach to the final chunk. - - Args: - attachments: List of attachments. - """ - self._attachments = attachments - - def set_sensitivity_label(self, sensitivity_label: SensitivityUsageInfo) -> None: - """ - Sets the sensitivity label to attach to the final chunk. - - Args: - sensitivity_label: The sensitivity label. - """ - self._sensitivity_label = sensitivity_label - - def set_citations(self, citations: List[Citation]) -> None: - """ - Sets the citations for the full message. - - Args: - citations: Citations to be included in the message. - """ - if citations: - if not self._citations: - self._citations = [] - - curr_pos = len(self._citations) - - for citation in citations: - client_citation = ClientCitation( - type="Claim", - position=curr_pos + 1, - appearance={ - "type": "DigitalDocument", - "name": citation.title or f"Document #{curr_pos + 1}", - "abstract": CitationUtil.snippet(citation.content, 477), - }, - ) - curr_pos += 1 - self._citations.append(client_citation) - - def set_feedback_loop(self, enable_feedback_loop: bool) -> None: - """ - Sets the Feedback Loop in Teams that allows a user to - give thumbs up or down to a response. - Default is False. - - Args: - enable_feedback_loop: If true, the feedback loop is enabled. - """ - self._enable_feedback_loop = enable_feedback_loop - - def set_feedback_loop_type( - self, feedback_loop_type: Literal["default", "custom"] - ) -> None: - """ - Sets the type of UI to use for the feedback loop. - - Args: - feedback_loop_type: The type of the feedback loop. - """ - self._feedback_loop_type = feedback_loop_type - - def set_generated_by_ai_label(self, enable_generated_by_ai_label: bool) -> None: - """ - Sets the Generated by AI label in Teams. - Default is False. - - Args: - enable_generated_by_ai_label: If true, the label is added. - """ - self._enable_generated_by_ai_label = enable_generated_by_ai_label - - def get_message(self) -> str: - """ - Returns the most recently streamed message. - """ - return self._message - - async def wait_for_queue(self) -> None: - """ - Waits for the outgoing activity queue to be empty. - """ - if self._queue_sync: - await self._queue_sync - - def _set_defaults(self, context: "TurnContext"): - if Channels.ms_teams == context.activity.channel_id.channel: - if context.activity.is_agentic_request(): - # Agentic requests do not support streaming responses at this time. - # TODO : Enable streaming for agentic requests when supported. - self._is_streaming_channel = False - else: - self._is_streaming_channel = True - self._interval = 1.0 - elif Channels.direct_line == context.activity.channel_id.channel: - self._is_streaming_channel = True - self._interval = 0.5 - elif context.activity.delivery_mode == DeliveryModes.stream: - self._is_streaming_channel = True - self._interval = 0.1 - - self._channel_id = context.activity.channel_id - - def _queue_next_chunk(self) -> None: - """ - Queues the next chunk of text to be sent to the client. - """ - # Are we already waiting to send a chunk? - if self._chunk_queued: - return - - # Queue a chunk of text to be sent - self._chunk_queued = True - - def create_activity(): - self._chunk_queued = False - if self._ended: - # Send final message - activity = Activity( - type="message", - text=self._message or "end stream response", - attachments=self._attachments or [], - entities=[ - Entity( - type="streaminfo", - stream_id=self._stream_id, - stream_type="final", - stream_sequence=self._sequence_number, - ) - ], - ) - elif self._is_streaming_channel: - # Send typing activity - activity = Activity( - type="typing", - text=self._message, - entities=[ - Entity( - type="streaminfo", - stream_type="streaming", - stream_sequence=self._sequence_number, - ) - ], - ) - else: - return - self._sequence_number += 1 - return activity - - self._queue_activity(create_activity) - - def _queue_activity(self, factory: Callable[[], Activity]) -> None: - """ - Queues an activity to be sent to the client. - """ - self._queue.append(factory) - - # If there's no sync in progress, start one - if not self._queue_sync: - self._queue_sync = asyncio.create_task(self._drain_queue()) - - async def _drain_queue(self) -> None: - """ - Sends any queued activities to the client until the queue is empty. - """ - try: - logger.debug(f"Draining queue with {len(self._queue)} activities.") - while self._queue: - factory = self._queue.pop(0) - activity = factory() - if activity: - await self._send_activity(activity) - except Exception as err: - if ( - "403" in str(err) - and self._context.activity.channel_id == Channels.ms_teams - ): - logger.warning("Teams channel stopped the stream.") - self._cancelled = True - else: - logger.error( - f"Error occurred when sending activity while streaming: {err}" - ) - raise - finally: - self._queue_sync = None - - async def _send_activity(self, activity: Activity) -> None: - """ - Sends an activity to the client and saves the stream ID returned. - - Args: - activity: The activity to send. - """ - - streaminfo_entity = None - - if not activity.entities: - streaminfo_entity = Entity(type="streaminfo") - activity.entities = [streaminfo_entity] - else: - for entity in activity.entities: - if hasattr(entity, "type") and entity.type == "streaminfo": - streaminfo_entity = entity - break - - if not streaminfo_entity: - # If no streaminfo entity exists, create one - streaminfo_entity = Entity(type="streaminfo") - activity.entities.append(streaminfo_entity) - - # Set activity ID to the assigned stream ID - if self._stream_id: - activity.id = self._stream_id - streaminfo_entity.stream_id = self._stream_id - - if self._citations and len(self._citations) > 0 and not self._ended: - # Filter out the citations unused in content. - curr_citations = CitationUtil.get_used_citations( - self._message, self._citations - ) - if curr_citations: - activity.entities.append( - Entity( - type="https://schema.org/Message", - schema_type="Message", - context="https://schema.org", - id="", - citation=curr_citations, - ) - ) - - # Add in Powered by AI feature flags - if self._ended: - if self._enable_feedback_loop and self._feedback_loop_type: - # Add feedback loop to streaminfo entity - streaminfo_entity.feedback_loop = {"type": self._feedback_loop_type} - else: - # Add feedback loop enabled to streaminfo entity - streaminfo_entity.feedback_loop_enabled = self._enable_feedback_loop - # Add in Generated by AI - if self._enable_generated_by_ai_label: - activity.add_ai_metadata(self._citations, self._sensitivity_label) - - # Send activity - response = await self._context.send_activity(activity) - await asyncio.sleep(self._interval) - - # Save assigned stream ID - if not self._stream_id and response: - self._stream_id = response.id diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py index 2d5b0fbf..f89857a8 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py @@ -1,9 +1,11 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import uuid import asyncio import logging from typing import List, Optional, Callable, Literal, TYPE_CHECKING +from dataclasses import dataclass from microsoft_agents.activity import ( Activity, @@ -15,8 +17,8 @@ SensitivityUsageInfo, ) -if TYPE_CHECKING: - from microsoft_agents.hosting.core.turn_context import TurnContext +from microsoft_agents.hosting.core import error_resources +from microsoft_agents.hosting.core.turn_context import TurnContext from .citation import Citation from .citation_util import CitationUtil @@ -47,58 +49,38 @@ def __init__(self, context: "TurnContext"): self._sequence_number = 1 self._stream_id: Optional[str] = None self._message = "" - self._attachments: Optional[List[Attachment]] = None - self._ended = False - self._cancelled = False - - # Queue for outgoing activities - self._queue: List[Callable[[], Activity]] = [] + self._queue: List[Callable[[], Activity | None]] = [] self._queue_sync: Optional[asyncio.Task] = None self._chunk_queued = False - - # Powered by AI feature flags + self._ended = False + self._cancelled = False + self._is_streaming_channel = False + self._interval = 0.1 + self._attachments: Optional[List[Attachment]] = None + self._citations: Optional[List[ClientCitation]] = None + self._sensitivity_label: Optional[SensitivityUsageInfo] = None self._enable_feedback_loop = False self._feedback_loop_type: Optional[Literal["default", "custom"]] = None self._enable_generated_by_ai_label = False - self._citations: Optional[List[ClientCitation]] = [] - self._sensitivity_label: Optional[SensitivityUsageInfo] = None - # Channel information - self._is_streaming_channel: bool = False - self._channel_id: Channels = None - self._interval: float = 0.1 # Default interval for sending updates + # Set defaults based on channel self._set_defaults(context) - @property - def stream_id(self) -> Optional[str]: - """ - Gets the stream ID of the current response. - Assigned after the initial update is sent. - """ - return self._stream_id - - @property - def citations(self) -> Optional[List[ClientCitation]]: - """Gets the citations of the current response.""" - return self._citations - - @property - def updates_sent(self) -> int: - """Gets the number of updates sent for the stream.""" - return self._sequence_number - 1 - def queue_informative_update(self, text: str) -> None: """ Queues an informative update to be sent to the client. + Informative updates do not contain the message content that the user will + read but rather an indication that the agent is processing the request. + Args: - text: Text of the update to send. + text: The informative text to send to the client. """ - if not self._is_streaming_channel: + if self._cancelled or not self._is_streaming_channel: return if self._ended: - raise RuntimeError("The stream has already ended.") + raise RuntimeError(str(error_resources.StreamAlreadyEnded)) # Queue a typing activity def create_activity(): @@ -134,7 +116,7 @@ def queue_text_chunk( if self._cancelled: return if self._ended: - raise RuntimeError("The stream has already ended.") + raise RuntimeError(str(error_resources.StreamAlreadyEnded)) # Update full message text self._message += text @@ -150,7 +132,7 @@ async def end_stream(self) -> None: Ends the stream by sending the final message to the client. """ if self._ended: - raise RuntimeError("The stream has already ended.") + raise RuntimeError(str(error_resources.StreamAlreadyEnded)) # Queue final message self._ended = True @@ -249,17 +231,27 @@ async def wait_for_queue(self) -> None: await self._queue_sync def _set_defaults(self, context: "TurnContext"): - if Channels.ms_teams == context.activity.channel_id.channel: - self._is_streaming_channel = True - self._interval = 1.0 - elif Channels.direct_line == context.activity.channel_id.channel: + + channel = context.activity.channel_id.channel if context.activity.channel_id else None + + if channel == Channels.ms_teams: + if context.activity.is_agentic_request(): + # Agentic requests do not support streaming responses at this time. + # TODO : Enable streaming for agentic requests when supported. + self._is_streaming_channel = False + else: + self._is_streaming_channel = True + self._interval = 1.0 + elif channel in [Channels.webchat, Channels.direct_line]: self._is_streaming_channel = True self._interval = 0.5 + self._stream_id = str(uuid.uuid4()) elif context.activity.delivery_mode == DeliveryModes.stream: self._is_streaming_channel = True self._interval = 0.1 - - self._channel_id = context.activity.channel_id + self._stream_id = str(uuid.uuid4()) + else: + self._is_streaming_channel = False def _queue_next_chunk(self) -> None: """ @@ -272,7 +264,7 @@ def _queue_next_chunk(self) -> None: # Queue a chunk of text to be sent self._chunk_queued = True - def create_activity(): + def create_activity() -> Activity | None: self._chunk_queued = False if self._ended: # Send final message @@ -283,7 +275,6 @@ def create_activity(): entities=[ Entity( type="streaminfo", - stream_id=self._stream_id, stream_type="final", stream_sequence=self._sequence_number, ) @@ -309,7 +300,7 @@ def create_activity(): self._queue_activity(create_activity) - def _queue_activity(self, factory: Callable[[], Activity]) -> None: + def _queue_activity(self, factory: Callable[[], Activity | None]) -> None: """ Queues an activity to be sent to the client. """ @@ -339,7 +330,7 @@ async def _drain_queue(self) -> None: self._cancelled = True else: logger.error( - f"Error occurred when sending activity while streaming: {err}" + f"Error occurred when sending activity while streaming: {type(err).__name__}" ) raise finally: @@ -374,7 +365,7 @@ async def _send_activity(self, activity: Activity) -> None: activity.id = self._stream_id streaminfo_entity.stream_id = self._stream_id - if self._citations and len(self._citations) > 0 and not self._ended: + if self._citations and not self._ended: # Filter out the citations unused in content. curr_citations = CitationUtil.get_used_citations( self._message, self._citations diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py index a3320b4b..f2f46d54 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py @@ -20,6 +20,7 @@ ) from microsoft_agents.activity.entity.entity_types import EntityTypes from microsoft_agents.hosting.core.authorization.claims_identity import ClaimsIdentity +from microsoft_agents.hosting.core.app.streaming import StreamingResponse class TurnContext(TurnContextProtocol): @@ -142,15 +143,7 @@ def streaming_response(self): """ # Use lazy import to avoid circular dependency if not hasattr(self, "_streaming_response"): - try: - from microsoft_agents.hosting.aiohttp.app.streaming import ( - StreamingResponse, - ) - - self._streaming_response = StreamingResponse(self) - except ImportError: - # If the hosting library isn't available, return None - self._streaming_response = None + self._streamign_response = StreamingRespone(self) return self._streaming_response @property diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/__init__.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/__init__.py deleted file mode 100644 index 8216be63..00000000 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .streaming import ( - Citation, - CitationUtil, - StreamingResponse, -) - -__all__ = [ - "Citation", - "CitationUtil", - "StreamingResponse", -] diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/__init__.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/__init__.py deleted file mode 100644 index 4cd61f38..00000000 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .citation import Citation -from .citation_util import CitationUtil -from .streaming_response import StreamingResponse - -__all__ = [ - "Citation", - "CitationUtil", - "StreamingResponse", -] diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/citation.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/citation.py deleted file mode 100644 index f643639a..00000000 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/citation.py +++ /dev/null @@ -1,22 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import Optional -from dataclasses import dataclass - - -@dataclass -class Citation: - """Citations returned by the model.""" - - content: str - """The content of the citation.""" - - title: Optional[str] = None - """The title of the citation.""" - - url: Optional[str] = None - """The URL of the citation.""" - - filepath: Optional[str] = None - """The filepath of the document.""" diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/citation_util.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/citation_util.py deleted file mode 100644 index 1ec923dc..00000000 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/citation_util.py +++ /dev/null @@ -1,85 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import re -from typing import List, Optional - -from microsoft_agents.activity import ClientCitation - - -class CitationUtil: - """Utility functions for manipulating text and citations.""" - - @staticmethod - def snippet(text: str, max_length: int) -> str: - """ - Clips the text to a maximum length in case it exceeds the limit. - - Args: - text: The text to clip. - max_length: The maximum length of the text to return, cutting off the last whole word. - - Returns: - The modified text - """ - if len(text) <= max_length: - return text - - snippet = text[:max_length] - snippet = snippet[: min(len(snippet), snippet.rfind(" "))] - snippet += "..." - return snippet - - @staticmethod - def format_citations_response(text: str) -> str: - """ - Convert citation tags `[doc(s)n]` to `[n]` where n is a number. - - Args: - text: The text to format. - - Returns: - The formatted text. - """ - return re.sub(r"\[docs?(\d+)\]", r"[\1]", text, flags=re.IGNORECASE) - - @staticmethod - def get_used_citations( - text: str, citations: List[ClientCitation] - ) -> Optional[List[ClientCitation]]: - """ - Get the citations used in the text. This will remove any citations that are - included in the citations array from the response but not referenced in the text. - - Args: - text: The text to search for citation references, i.e. [1], [2], etc. - citations: The list of citations to search for. - - Returns: - The list of citations used in the text. - """ - regex = re.compile(r"\[(\d+)\]", re.IGNORECASE) - matches = regex.findall(text) - - if not matches: - return None - - # Remove duplicates - filtered_matches = set(matches) - - # Add citations - used_citations = [] - for match in filtered_matches: - citation_ref = f"[{match}]" - found = next( - ( - citation - for citation in citations - if f"[{citation.position}]" == citation_ref - ), - None, - ) - if found: - used_citations.append(found) - - return used_citations if used_citations else None diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/streaming_response.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/streaming_response.py deleted file mode 100644 index 7d837dfe..00000000 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/streaming_response.py +++ /dev/null @@ -1,392 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import asyncio -import logging -from typing import List, Optional, Callable, Literal, TYPE_CHECKING -from dataclasses import dataclass - -from microsoft_agents.activity import ( - Activity, - Entity, - Attachment, - Channels, - ClientCitation, - DeliveryModes, - SensitivityUsageInfo, -) - -from microsoft_agents.hosting.core import error_resources -from microsoft_agents.hosting.core.turn_context import TurnContext - -from .citation import Citation -from .citation_util import CitationUtil - -logger = logging.getLogger(__name__) - - -class StreamingResponse: - """ - A helper class for streaming responses to the client. - - This class is used to send a series of updates to the client in a single response. - The expected sequence of calls is: - - `queue_informative_update()`, `queue_text_chunk()`, `queue_text_chunk()`, ..., `end_stream()`. - - Once `end_stream()` is called, the stream is considered ended and no further updates can be sent. - """ - - def __init__(self, context: "TurnContext"): - """ - Creates a new StreamingResponse instance. - - Args: - context: Context for the current turn of conversation with the user. - """ - self._context = context - self._sequence_number = 1 - self._stream_id: Optional[str] = None - self._message = "" - self._queue: List[Callable[[], Activity]] = [] - self._queue_sync: Optional[asyncio.Task] = None - self._chunk_queued = False - self._ended = False - self._cancelled = False - self._is_streaming_channel = False - self._interval = 0.1 - self._channel_id: Optional[str] = None - self._attachments: Optional[List[Attachment]] = None - self._citations: Optional[List[ClientCitation]] = None - self._sensitivity_label: Optional[SensitivityUsageInfo] = None - self._enable_feedback_loop = False - self._feedback_loop_type: Optional[Literal["default", "custom"]] = None - self._enable_generated_by_ai_label = False - - # Set defaults based on channel - self._set_defaults(context) - - def queue_informative_update(self, text: str) -> None: - """ - Queues an informative update to be sent to the client. - - Informative updates do not contain the message content that the user will - read but rather an indication that the agent is processing the request. - - Args: - text: The informative text to send to the client. - """ - if self._cancelled: - return - - if self._ended: - raise RuntimeError(str(error_resources.StreamAlreadyEnded)) - - # Queue a typing activity - def create_activity(): - activity = Activity( - type="typing", - text=text, - entities=[ - Entity( - type="streaminfo", - stream_type="informative", - stream_sequence=self._sequence_number, - ) - ], - ) - self._sequence_number += 1 - return activity - - self._queue_activity(create_activity) - - def queue_text_chunk( - self, text: str, citations: Optional[List[Citation]] = None - ) -> None: - """ - Queues a chunk of partial message text to be sent to the client. - - The text will be sent as quickly as possible to the client. - Chunks may be combined before delivery to the client. - - Args: - text: Partial text of the message to send. - citations: Citations to be included in the message. - """ - if self._cancelled: - return - if self._ended: - raise RuntimeError(str(error_resources.StreamAlreadyEnded)) - - # Update full message text - self._message += text - - # If there are citations, modify the content so that the sources are numbers instead of [doc1], [doc2], etc. - self._message = CitationUtil.format_citations_response(self._message) - - # Queue the next chunk - self._queue_next_chunk() - - async def end_stream(self) -> None: - """ - Ends the stream by sending the final message to the client. - """ - if self._ended: - raise RuntimeError(str(error_resources.StreamAlreadyEnded)) - - # Queue final message - self._ended = True - self._queue_next_chunk() - - # Wait for the queue to drain - await self.wait_for_queue() - - def set_attachments(self, attachments: List[Attachment]) -> None: - """ - Sets the attachments to attach to the final chunk. - - Args: - attachments: List of attachments. - """ - self._attachments = attachments - - def set_sensitivity_label(self, sensitivity_label: SensitivityUsageInfo) -> None: - """ - Sets the sensitivity label to attach to the final chunk. - - Args: - sensitivity_label: The sensitivity label. - """ - self._sensitivity_label = sensitivity_label - - def set_citations(self, citations: List[Citation]) -> None: - """ - Sets the citations for the full message. - - Args: - citations: Citations to be included in the message. - """ - if citations: - if not self._citations: - self._citations = [] - - curr_pos = len(self._citations) - - for citation in citations: - client_citation = ClientCitation( - type="Claim", - position=curr_pos + 1, - appearance={ - "type": "DigitalDocument", - "name": citation.title or f"Document #{curr_pos + 1}", - "abstract": CitationUtil.snippet(citation.content, 477), - }, - ) - curr_pos += 1 - self._citations.append(client_citation) - - def set_feedback_loop(self, enable_feedback_loop: bool) -> None: - """ - Sets the Feedback Loop in Teams that allows a user to - give thumbs up or down to a response. - Default is False. - - Args: - enable_feedback_loop: If true, the feedback loop is enabled. - """ - self._enable_feedback_loop = enable_feedback_loop - - def set_feedback_loop_type( - self, feedback_loop_type: Literal["default", "custom"] - ) -> None: - """ - Sets the type of UI to use for the feedback loop. - - Args: - feedback_loop_type: The type of the feedback loop. - """ - self._feedback_loop_type = feedback_loop_type - - def set_generated_by_ai_label(self, enable_generated_by_ai_label: bool) -> None: - """ - Sets the Generated by AI label in Teams. - Default is False. - - Args: - enable_generated_by_ai_label: If true, the label is added. - """ - self._enable_generated_by_ai_label = enable_generated_by_ai_label - - def get_message(self) -> str: - """ - Returns the most recently streamed message. - """ - return self._message - - async def wait_for_queue(self) -> None: - """ - Waits for the outgoing activity queue to be empty. - """ - if self._queue_sync: - await self._queue_sync - - def _set_defaults(self, context: "TurnContext"): - if context.activity.channel_id == Channels.ms_teams: - self._is_streaming_channel = True - self._interval = 1.0 - elif context.activity.channel_id == Channels.direct_line: - self._is_streaming_channel = True - self._interval = 0.5 - elif context.activity.delivery_mode == DeliveryModes.stream: - self._is_streaming_channel = True - self._interval = 0.1 - - self._channel_id = context.activity.channel_id - - def _queue_next_chunk(self) -> None: - """ - Queues the next chunk of text to be sent to the client. - """ - # Are we already waiting to send a chunk? - if self._chunk_queued: - return - - # Queue a chunk of text to be sent - self._chunk_queued = True - - def create_activity(): - self._chunk_queued = False - if self._ended: - # Send final message - activity = Activity( - type="message", - text=self._message or "end stream response", - attachments=self._attachments or [], - entities=[ - Entity( - type="streaminfo", - stream_type="final", - stream_sequence=self._sequence_number, - ) - ], - ) - elif self._is_streaming_channel: - # Send typing activity - activity = Activity( - type="typing", - text=self._message, - entities=[ - Entity( - type="streaminfo", - stream_type="streaming", - stream_sequence=self._sequence_number, - ) - ], - ) - else: - return - self._sequence_number += 1 - return activity - - self._queue_activity(create_activity) - - def _queue_activity(self, factory: Callable[[], Activity]) -> None: - """ - Queues an activity to be sent to the client. - """ - self._queue.append(factory) - - # If there's no sync in progress, start one - if not self._queue_sync: - self._queue_sync = asyncio.create_task(self._drain_queue()) - - async def _drain_queue(self) -> None: - """ - Sends any queued activities to the client until the queue is empty. - """ - try: - logger.debug(f"Draining queue with {len(self._queue)} activities.") - while self._queue: - factory = self._queue.pop(0) - activity = factory() - if activity: - await self._send_activity(activity) - except Exception as err: - if ( - "403" in str(err) - and self._context.activity.channel_id == Channels.ms_teams - ): - logger.warning("Teams channel stopped the stream.") - self._cancelled = True - else: - logger.error( - f"Error occurred when sending activity while streaming: {type(err).__name__}" - ) - raise - finally: - self._queue_sync = None - - async def _send_activity(self, activity: Activity) -> None: - """ - Sends an activity to the client and saves the stream ID returned. - - Args: - activity: The activity to send. - """ - - streaminfo_entity = None - - if not activity.entities: - streaminfo_entity = Entity(type="streaminfo") - activity.entities = [streaminfo_entity] - else: - for entity in activity.entities: - if hasattr(entity, "type") and entity.type == "streaminfo": - streaminfo_entity = entity - break - - if not streaminfo_entity: - # If no streaminfo entity exists, create one - streaminfo_entity = Entity(type="streaminfo") - activity.entities.append(streaminfo_entity) - - # Set activity ID to the assigned stream ID - if self._stream_id: - activity.id = self._stream_id - streaminfo_entity.stream_id = self._stream_id - - if self._citations and not self._ended: - # Filter out the citations unused in content. - curr_citations = CitationUtil.get_used_citations( - self._message, self._citations - ) - if curr_citations: - activity.entities.append( - Entity( - type="https://schema.org/Message", - schema_type="Message", - context="https://schema.org", - id="", - citation=curr_citations, - ) - ) - - # Add in Powered by AI feature flags - if self._ended: - if self._enable_feedback_loop and self._feedback_loop_type: - # Add feedback loop to streaminfo entity - streaminfo_entity.feedback_loop = {"type": self._feedback_loop_type} - else: - # Add feedback loop enabled to streaminfo entity - streaminfo_entity.feedback_loop_enabled = self._enable_feedback_loop - # Add in Generated by AI - if self._enable_generated_by_ai_label: - activity.add_ai_metadata(self._citations, self._sensitivity_label) - - # Send activity - response = await self._context.send_activity(activity) - await asyncio.sleep(self._interval) - - # Save assigned stream ID - if not self._stream_id and response: - self._stream_id = response.id From e8aa9b28cd6f62c414197c666c8d4349bf593273 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 25 Feb 2026 09:15:13 -0800 Subject: [PATCH 02/29] Improving StreamingResponse tests and formatting --- .../core/app/streaming/streaming_response.py | 7 +- .../hosting/core/turn_context.py | 5 +- tests/hosting_core/app/streaming/__init__.py | 0 .../app/streaming/test_streaming_response.py | 358 ++++++++++++++++++ 4 files changed, 365 insertions(+), 5 deletions(-) create mode 100644 tests/hosting_core/app/streaming/__init__.py create mode 100644 tests/hosting_core/app/streaming/test_streaming_response.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py index f89857a8..cde42b72 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py @@ -17,8 +17,7 @@ SensitivityUsageInfo, ) -from microsoft_agents.hosting.core import error_resources -from microsoft_agents.hosting.core.turn_context import TurnContext +from microsoft_agents.hosting.core.errors import error_resources from .citation import Citation from .citation_util import CitationUtil @@ -232,7 +231,9 @@ async def wait_for_queue(self) -> None: def _set_defaults(self, context: "TurnContext"): - channel = context.activity.channel_id.channel if context.activity.channel_id else None + channel = ( + context.activity.channel_id.channel if context.activity.channel_id else None + ) if channel == Channels.ms_teams: if context.activity.is_agentic_request(): diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py index f2f46d54..56165e93 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py @@ -20,7 +20,6 @@ ) from microsoft_agents.activity.entity.entity_types import EntityTypes from microsoft_agents.hosting.core.authorization.claims_identity import ClaimsIdentity -from microsoft_agents.hosting.core.app.streaming import StreamingResponse class TurnContext(TurnContextProtocol): @@ -143,7 +142,9 @@ def streaming_response(self): """ # Use lazy import to avoid circular dependency if not hasattr(self, "_streaming_response"): - self._streamign_response = StreamingRespone(self) + from microsoft_agents.hosting.core.app.streaming import StreamingResponse + + self._streaming_response = StreamingResponse(self) return self._streaming_response @property diff --git a/tests/hosting_core/app/streaming/__init__.py b/tests/hosting_core/app/streaming/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hosting_core/app/streaming/test_streaming_response.py b/tests/hosting_core/app/streaming/test_streaming_response.py new file mode 100644 index 00000000..a0c6cbf3 --- /dev/null +++ b/tests/hosting_core/app/streaming/test_streaming_response.py @@ -0,0 +1,358 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import asyncio + +import pytest + +from microsoft_agents.activity import ( + Activity, + ChannelId, + Channels, + DeliveryModes, + ResourceResponse, +) +from microsoft_agents.hosting.core.app.streaming.citation import Citation +from microsoft_agents.hosting.core.app.streaming.streaming_response import ( + StreamingResponse, +) +from microsoft_agents.hosting.core.turn_context import TurnContext + + +STREAMING_CHANNELS = [Channels.webchat, Channels.ms_teams, Channels.direct_line] +NON_STREAMING_CHANNELS = [Channels.test, Channels.slack, Channels.email] + + +@pytest.fixture(name="non_streaming_channel", params=NON_STREAMING_CHANNELS) +def fixture_non_streaming_channel(request) -> ChannelId: + return ChannelId(channel=request.param) + + +@pytest.fixture(name="streaming_channel", params=STREAMING_CHANNELS) +def fixture_streaming_channel(request) -> ChannelId: + return ChannelId(channel=request.param) + + +def _create_turn_context( + mocker, + *, + channel_id: ChannelId | Channels = Channels.webchat, + delivery_mode: str | None = DeliveryModes.stream, + send_side_effect=None, +): + if isinstance(channel_id, Channels): + channel_id = ChannelId(channel=channel_id) + + context = mocker.MagicMock(spec=TurnContext) + activity = mocker.MagicMock(spec=Activity) + activity.channel_id = channel_id + activity.delivery_mode = delivery_mode + activity.is_agentic_request.return_value = False + context.activity = activity + context.send_activity = mocker.AsyncMock(side_effect=send_side_effect) + return context + + +@pytest.mark.asyncio +async def test_queue_informative_update_is_ignored_for_non_streaming_channel(mocker): + context = _create_turn_context( + mocker, + delivery_mode=DeliveryModes.normal, + channel_id=Channels.slack, + ) + response = StreamingResponse(context) + + response.queue_informative_update("working") + await response.wait_for_queue() + + context.send_activity.assert_not_called() + + +@pytest.mark.asyncio +async def test_queue_text_chunk_and_end_stream_send_streaming_then_final_message(mocker): + context = _create_turn_context( + mocker, + delivery_mode=DeliveryModes.stream, + send_side_effect=[ResourceResponse(id="stream-1")], + ) + response = StreamingResponse(context) + + response.queue_text_chunk("Hello [doc1]") + await response.end_stream() + + assert context.send_activity.await_count == 1 + + final_activity = context.send_activity.await_args_list[0].args[0] + + assert final_activity.type == "message" + assert final_activity.id != "" + assert final_activity.text == "Hello [1]" + assert final_activity.entities[0].stream_type == "final" + + +@pytest.mark.asyncio +async def test_multiple_queued_text_chunks_are_coalesced_into_one_final_activity(mocker): + context = _create_turn_context( + mocker, + delivery_mode=DeliveryModes.stream, + send_side_effect=[ResourceResponse(id="stream-1")], + ) + response = StreamingResponse(context) + + response.queue_text_chunk("Hello") + response.queue_text_chunk(" ") + response.queue_text_chunk("world") + await response.end_stream() + + assert context.send_activity.await_count == 1 + final_activity = context.send_activity.await_args_list[0].args[0] + assert final_activity.text == "Hello world" + assert final_activity.entities[0].stream_type == "final" + + +@pytest.mark.asyncio +async def test_set_citations_only_sends_final_when_end_stream_happens_before_drain(mocker): + context = _create_turn_context( + mocker, + delivery_mode=DeliveryModes.stream, + send_side_effect=[ResourceResponse(id="stream-2")], + ) + response = StreamingResponse(context) + response.set_citations( + [ + Citation(content="Document one content", title="Doc One"), + Citation(content="Document two content", title="Doc Two"), + ] + ) + + response.queue_text_chunk("Answer with citation [1].") + await response.end_stream() + + assert context.send_activity.await_count == 1 + final_activity = context.send_activity.await_args_list[0].args[0] + + citation_entities = [ + entity + for entity in final_activity.entities + if getattr(entity, "schema_type", None) == "Message" + ] + assert len(citation_entities) == 0 + + +@pytest.mark.asyncio +async def test_set_citations_adds_only_used_citations_when_streaming_activity_is_sent(mocker): + context = _create_turn_context( + mocker, + delivery_mode=DeliveryModes.stream, + send_side_effect=[ResourceResponse(id="stream-2"), ResourceResponse(id="stream-2")], + ) + response = StreamingResponse(context) + response.set_citations( + [ + Citation(content="Document one content", title="Doc One"), + Citation(content="Document two content", title="Doc Two"), + ] + ) + + response.queue_text_chunk("Answer with citation [1].") + await response.wait_for_queue() + await response.end_stream() + + assert context.send_activity.await_count == 2 + streaming_activity = context.send_activity.await_args_list[0].args[0] + + citation_entities = [ + entity + for entity in streaming_activity.entities + if getattr(entity, "schema_type", None) == "Message" + ] + assert len(citation_entities) == 1 + assert len(citation_entities[0].citation) == 1 + assert citation_entities[0].citation[0].position == 1 + + +@pytest.mark.asyncio +async def test_end_stream_cannot_be_called_twice(mocker): + context = _create_turn_context( + mocker, + delivery_mode=DeliveryModes.stream, + send_side_effect=[ResourceResponse(id="stream-3")], + ) + response = StreamingResponse(context) + + response.queue_text_chunk("Done") + await response.end_stream() + + with pytest.raises(RuntimeError, match="already ended"): + await response.end_stream() + + +@pytest.mark.asyncio +async def test_teams_403_marks_stream_as_cancelled_and_future_chunks_are_ignored(mocker): + context = _create_turn_context( + mocker, + channel_id=Channels.ms_teams, + send_side_effect=Exception("403 forbidden"), + ) + response = StreamingResponse(context) + + response.queue_text_chunk("first") + await response.wait_for_queue() + + response.queue_text_chunk(" second") + await response.wait_for_queue() + + assert context.send_activity.await_count == 1 + assert response.get_message() == "first" + + +@pytest.mark.asyncio +async def test_feedback_loop_type_added_to_final_streaminfo_entity(mocker): + context = _create_turn_context( + mocker, + delivery_mode=DeliveryModes.stream, + send_side_effect=[ResourceResponse(id="stream-4")], + ) + response = StreamingResponse(context) + response.set_feedback_loop(True) + response.set_feedback_loop_type("custom") + + response.queue_text_chunk("feedback") + await response.end_stream() + + final_activity = context.send_activity.await_args_list[-1].args[0] + stream_info = next( + entity for entity in final_activity.entities if entity.type == "streaminfo" + ) + + assert stream_info.feedback_loop == {"type": "custom"} + + +@pytest.mark.asyncio +async def test_generated_by_ai_label_adds_ai_entity_on_final_message(mocker): + context = _create_turn_context( + mocker, + delivery_mode=DeliveryModes.stream, + send_side_effect=[ResourceResponse(id="stream-5")], + ) + response = StreamingResponse(context) + response.set_citations([Citation(content="Document one content", title="Doc One")]) + response.set_generated_by_ai_label(True) + + response.queue_text_chunk("See [1]") + await response.end_stream() + + final_activity = context.send_activity.await_args_list[-1].args[0] + ai_entities = [ + entity + for entity in final_activity.entities + if "AIGeneratedContent" in (getattr(entity, "additional_type", None) or []) + ] + + assert len(ai_entities) == 1 + + +@pytest.mark.asyncio +async def test_streaming_operations_with_sleeps_send_informative_and_text_updates(mocker): + context = _create_turn_context( + mocker, + channel_id=Channels.test, + delivery_mode=DeliveryModes.stream, + send_side_effect=[ + ResourceResponse(id="stream-10"), + ResourceResponse(id="stream-10"), + ResourceResponse(id="stream-10"), + ResourceResponse(id="stream-10"), + ], + ) + response = StreamingResponse(context) + + response.queue_informative_update("Searching documents") + await asyncio.sleep(0.2) + + response.queue_text_chunk("Hello") + await asyncio.sleep(0.2) + + response.queue_text_chunk(" world") + await asyncio.sleep(0.2) + + await response.end_stream() + + assert context.send_activity.await_count == 4 + + sent_activities = [call.args[0] for call in context.send_activity.await_args_list] + stream_types = [ + next(entity for entity in activity.entities if entity.type == "streaminfo").stream_type + for activity in sent_activities + ] + sent_types = [activity.type for activity in sent_activities] + + assert sent_types == ["typing", "typing", "typing", "message"] + assert stream_types == ["informative", "streaming", "streaming", "final"] + assert sent_activities[-1].text == "Hello world" + + +@pytest.mark.asyncio +async def test_streaming_loop_with_sleep_emits_informative_and_streaming_updates(mocker): + context = _create_turn_context( + mocker, + channel_id=Channels.test, + delivery_mode=DeliveryModes.stream, + send_side_effect=[ + ResourceResponse(id="stream-11"), + ResourceResponse(id="stream-11"), + ResourceResponse(id="stream-11"), + ResourceResponse(id="stream-11"), + ResourceResponse(id="stream-11"), + ResourceResponse(id="stream-11"), + ResourceResponse(id="stream-11"), + ], + ) + response = StreamingResponse(context) + + updates = [ + ("Thinking", "Alpha "), + ("Drafting", "Beta "), + ("Finalizing", "Gamma"), + ] + + for informative_text, chunk in updates: + response.queue_informative_update(informative_text) + response.queue_text_chunk(chunk) + await asyncio.sleep(0.3) + + await response.end_stream() + + sent_activities = [call.args[0] for call in context.send_activity.await_args_list] + stream_types = [ + next(entity for entity in activity.entities if entity.type == "streaminfo").stream_type + for activity in sent_activities + ] + + assert stream_types.count("informative") == 3 + assert stream_types.count("streaming") == 3 + assert stream_types[-1] == "final" + assert sent_activities[-1].type == "message" + assert sent_activities[-1].text == "Alpha Beta Gamma" + + +class TestStreamingResponseNonStreamingChannel: + + @pytest.mark.asyncio + async def test_queue_text_chunk_and_end_stream_send_final_message( + self, mocker, non_streaming_channel + ): + context = _create_turn_context(mocker, channel_id=non_streaming_channel) + response = StreamingResponse(context) + + response.queue_text_chunk("Hello") + await response.end_stream() + + assert context.send_activity.await_count == 1 + final_activity = context.send_activity.await_args_list[0].args[0] + + assert final_activity.type == "message" + assert final_activity.text == "Hello" + assert final_activity.entities[0].stream_type == "final" \ No newline at end of file From c730a89bb062eb2a879bbcde3833f57c4b514ad2 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 26 Feb 2026 11:45:19 -0800 Subject: [PATCH 03/29] Adding tests for StreamingResponse --- .../app/streaming/test_streaming_response.py | 165 ++++++++++++++++-- 1 file changed, 152 insertions(+), 13 deletions(-) diff --git a/tests/hosting_core/app/streaming/test_streaming_response.py b/tests/hosting_core/app/streaming/test_streaming_response.py index a0c6cbf3..9754fcbc 100644 --- a/tests/hosting_core/app/streaming/test_streaming_response.py +++ b/tests/hosting_core/app/streaming/test_streaming_response.py @@ -40,7 +40,7 @@ def _create_turn_context( *, channel_id: ChannelId | Channels = Channels.webchat, delivery_mode: str | None = DeliveryModes.stream, - send_side_effect=None, + return_value=None, ): if isinstance(channel_id, Channels): channel_id = ChannelId(channel=channel_id) @@ -51,7 +51,10 @@ def _create_turn_context( activity.delivery_mode = delivery_mode activity.is_agentic_request.return_value = False context.activity = activity - context.send_activity = mocker.AsyncMock(side_effect=send_side_effect) + if isinstance(return_value, list) and len(return_value) > 0: + context.send_activity = mocker.AsyncMock(side_effect=return_value) + else: + context.send_activity = mocker.AsyncMock(return_value=return_value) return context @@ -75,7 +78,7 @@ async def test_queue_text_chunk_and_end_stream_send_streaming_then_final_message context = _create_turn_context( mocker, delivery_mode=DeliveryModes.stream, - send_side_effect=[ResourceResponse(id="stream-1")], + return_value=ResourceResponse(id="stream-1"), ) response = StreamingResponse(context) @@ -97,7 +100,7 @@ async def test_multiple_queued_text_chunks_are_coalesced_into_one_final_activity context = _create_turn_context( mocker, delivery_mode=DeliveryModes.stream, - send_side_effect=[ResourceResponse(id="stream-1")], + return_value=ResourceResponse(id="stream-1"), ) response = StreamingResponse(context) @@ -117,7 +120,7 @@ async def test_set_citations_only_sends_final_when_end_stream_happens_before_dra context = _create_turn_context( mocker, delivery_mode=DeliveryModes.stream, - send_side_effect=[ResourceResponse(id="stream-2")], + return_value=[ResourceResponse(id="stream-2")], ) response = StreamingResponse(context) response.set_citations( @@ -146,7 +149,7 @@ async def test_set_citations_adds_only_used_citations_when_streaming_activity_is context = _create_turn_context( mocker, delivery_mode=DeliveryModes.stream, - send_side_effect=[ResourceResponse(id="stream-2"), ResourceResponse(id="stream-2")], + return_value=[ResourceResponse(id="stream-2"), ResourceResponse(id="stream-2")], ) response = StreamingResponse(context) response.set_citations( @@ -178,7 +181,7 @@ async def test_end_stream_cannot_be_called_twice(mocker): context = _create_turn_context( mocker, delivery_mode=DeliveryModes.stream, - send_side_effect=[ResourceResponse(id="stream-3")], + return_value=[ResourceResponse(id="stream-3")], ) response = StreamingResponse(context) @@ -191,11 +194,13 @@ async def test_end_stream_cannot_be_called_twice(mocker): @pytest.mark.asyncio async def test_teams_403_marks_stream_as_cancelled_and_future_chunks_are_ignored(mocker): + context = _create_turn_context( mocker, channel_id=Channels.ms_teams, - send_side_effect=Exception("403 forbidden"), + return_value=[RuntimeError("403 Forbidden: Stream cancelled by user"), ResourceResponse(id="stream-4")], ) + response = StreamingResponse(context) response.queue_text_chunk("first") @@ -213,7 +218,7 @@ async def test_feedback_loop_type_added_to_final_streaminfo_entity(mocker): context = _create_turn_context( mocker, delivery_mode=DeliveryModes.stream, - send_side_effect=[ResourceResponse(id="stream-4")], + return_value=[ResourceResponse(id="stream-4")], ) response = StreamingResponse(context) response.set_feedback_loop(True) @@ -235,7 +240,7 @@ async def test_generated_by_ai_label_adds_ai_entity_on_final_message(mocker): context = _create_turn_context( mocker, delivery_mode=DeliveryModes.stream, - send_side_effect=[ResourceResponse(id="stream-5")], + return_value=[ResourceResponse(id="stream-5")], ) response = StreamingResponse(context) response.set_citations([Citation(content="Document one content", title="Doc One")]) @@ -260,7 +265,7 @@ async def test_streaming_operations_with_sleeps_send_informative_and_text_update mocker, channel_id=Channels.test, delivery_mode=DeliveryModes.stream, - send_side_effect=[ + return_value=[ ResourceResponse(id="stream-10"), ResourceResponse(id="stream-10"), ResourceResponse(id="stream-10"), @@ -300,7 +305,7 @@ async def test_streaming_loop_with_sleep_emits_informative_and_streaming_updates mocker, channel_id=Channels.test, delivery_mode=DeliveryModes.stream, - send_side_effect=[ + return_value=[ ResourceResponse(id="stream-11"), ResourceResponse(id="stream-11"), ResourceResponse(id="stream-11"), @@ -355,4 +360,138 @@ async def test_queue_text_chunk_and_end_stream_send_final_message( assert final_activity.type == "message" assert final_activity.text == "Hello" - assert final_activity.entities[0].stream_type == "final" \ No newline at end of file + assert final_activity.entities[0].stream_type == "final" + + +@pytest.mark.asyncio +async def test_wait_for_queue_is_noop_when_nothing_was_queued(mocker): + context = _create_turn_context(mocker, delivery_mode=DeliveryModes.stream) + response = StreamingResponse(context) + + await response.wait_for_queue() + + context.send_activity.assert_not_called() + + +@pytest.mark.asyncio +async def test_end_stream_without_chunks_sends_default_final_text(mocker): + context = _create_turn_context( + mocker, + delivery_mode=DeliveryModes.stream, + return_value=ResourceResponse(id="stream-1"), + ) + response = StreamingResponse(context) + + await response.end_stream() + + context.send_activity.assert_awaited_once() + sent = context.send_activity.await_args.args[0] + assert sent.type == "message" + assert sent.text == "end stream response" + + +@pytest.mark.asyncio +async def test_non_streaming_channel_buffers_text_and_only_sends_on_end(mocker): + context = _create_turn_context( + mocker, + channel_id=Channels.emulator, # non-streaming branch + delivery_mode=DeliveryModes.normal, + return_value=ResourceResponse(id="final-1"), + ) + response = StreamingResponse(context) + + response.queue_text_chunk("Hello") + response.queue_text_chunk(" world") + + await asyncio.sleep(1) + + # Should not send partial updates on non-streaming channels + context.send_activity.assert_not_called() + + await response.end_stream() + + context.send_activity.assert_awaited_once() + sent = context.send_activity.await_args.args[0] + assert sent.type == "message" + assert sent.text == "Hello world" + + +@pytest.mark.asyncio +async def test_queue_informative_update_is_noop_on_non_streaming_channel(mocker): + context = _create_turn_context( + mocker, + channel_id=Channels.emulator, # non-streaming + delivery_mode=None, + ) + response = StreamingResponse(context) + + response.queue_informative_update("Working...") + await response.wait_for_queue() + + context.send_activity.assert_not_called() + + +@pytest.mark.asyncio +async def test_public_methods_raise_after_end_stream(mocker): + context = _create_turn_context( + mocker, + delivery_mode=DeliveryModes.stream, + return_value=ResourceResponse(id="stream-2"), + ) + response = StreamingResponse(context) + + response.queue_text_chunk("done") + await response.end_stream() + + with pytest.raises(RuntimeError): + response.queue_text_chunk("extra") + + with pytest.raises(RuntimeError): + response.queue_informative_update("extra") + + with pytest.raises(RuntimeError): + await response.end_stream() + + +@pytest.mark.asyncio +async def test_queue_text_chunk_citations_argument_is_currently_ignored(mocker): + context = _create_turn_context( + mocker, + delivery_mode=DeliveryModes.stream, + return_value=ResourceResponse(id="stream-3"), + ) + response = StreamingResponse(context) + + response.queue_text_chunk( + "Answer with [doc1]", + citations=[Citation(title="Doc 1", content="Citation content")], + ) + await response.wait_for_queue() + + sent = context.send_activity.await_args.args[0] + entity_types = [getattr(e, "type", None) for e in (sent.entities or [])] + + # streaminfo should exist; schema.org citation entity should not + assert "streaminfo" in entity_types + assert "https://schema.org/Message" not in entity_types + + +@pytest.mark.asyncio +async def test_feedback_loop_type_without_enable_does_not_emit_feedback_loop_object(mocker): + context = _create_turn_context( + mocker, + channel_id=Channels.ms_teams, + return_value=ResourceResponse(id="teams-1"), + ) + response = StreamingResponse(context) + response._interval = 0 + + response.set_feedback_loop_type("custom") + response.queue_text_chunk("hello") + await response.end_stream() + + sent = context.send_activity.await_args_list[-1].args[0] + streaminfo = next(e for e in sent.entities if getattr(e, "type", None) == "streaminfo") + + assert not hasattr(streaminfo, "feedback_loop") + assert getattr(streaminfo, "feedback_loop_enabled", None) is False From ead6403d5bbd5999184dd63e621ccfd01e765080 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 26 Feb 2026 14:46:33 -0800 Subject: [PATCH 04/29] Adding basic StreamingResponse tests --- dev/tests/sdk/test_streaming_response.py | 82 ++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 dev/tests/sdk/test_streaming_response.py diff --git a/dev/tests/sdk/test_streaming_response.py b/dev/tests/sdk/test_streaming_response.py new file mode 100644 index 00000000..669c584c --- /dev/null +++ b/dev/tests/sdk/test_streaming_response.py @@ -0,0 +1,82 @@ +import pytest +import asyncio + +from microsoft_agents.activity import ActivityTypes + +from microsoft_agents.hosting.core import ( + TurnContext, + TurnState, +) + +from microsoft_agents.testing import ( + AgentClient, + AgentEnvironment, + AiohttpScenario, +) + +FULL_TEXT = "This is a streaming response." +CHUNKS = FULL_TEXT.split() + +async def init_agent(env: AgentEnvironment): + + app = env.agent_application + + @app.message("/stream") + async def stream_handler(context: TurnContext, state: TurnState): + + assert context.streaming_response is not None + + context.streaming_response.queue_informative_update("Starting stream...") + + for chunk in CHUNKS: + await asyncio.sleep(1.0) # Simulate delay between chunks + context.streaming_response.queue_text_chunk(chunk) + + await asyncio.sleep(1.0) + + await context.streaming_response.end_stream() + +_SCENARIO = AiohttpScenario(init_agent=init_agent, use_jwt_middleware=False) + +@pytest.mark.asyncio +@pytest.mark.agent_test(_SCENARIO) +async def test_basic_streaming_response(agent_client: AgentClient): + + expected_len = len(FULL_TEXT.split()) + + # give enough time for all the activities to send + await agent_client.send("/stream", wait=expected_len * 2.0) + + stream_activities = agent_client.select().where( + entities=lambda x: any(e.type == "streaminfo" for e in x) + ) + + assert len(stream_activities) == len(CHUNKS) + 2 + + informative = stream_activities[0] + informative_streaminfo = informative[0].entities.first(lambda e: e.type == "streaminfo") + + assert informative_streaminfo.stream_type == "informative" + assert informative_streaminfo.stream_sequence == 0 + assert informative.text == "Starting stream..." + assert informative.type == ActivityTypes.typing + + t = "" + for i, chunk in enumerate(CHUNKS): + t += chunk + + j = i + 1 + + streaminfo = stream_activities[j].entities.first(lambda e: e.type == "streaminfo") + + assert stream_activities[j].text == "" + assert stream_activities[j].type == ActivityTypes.typing + assert streaminfo.stream_type == "streaming" + assert streaminfo.stream_sequence == j + + final_streaminfo = stream_activities[-1].entities.first(lambda e: e.type == "streaminfo") + + assert final_streaminfo.stream_sequence == len(CHUNKS) + 2 + assert final_streaminfo.stream_type == "final" + assert stream_activities[-1].text == FULL_TEXT + From 03ed8f7816fc0f29cc0a3c6add51643e6d0c5eca Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 26 Feb 2026 14:47:27 -0800 Subject: [PATCH 05/29] Adding integration tests for streaming --- .../app/streaming/test_streaming_response.py | 50 ++++++++++++++----- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/tests/hosting_core/app/streaming/test_streaming_response.py b/tests/hosting_core/app/streaming/test_streaming_response.py index 9754fcbc..7c3630a5 100644 --- a/tests/hosting_core/app/streaming/test_streaming_response.py +++ b/tests/hosting_core/app/streaming/test_streaming_response.py @@ -20,7 +20,6 @@ ) from microsoft_agents.hosting.core.turn_context import TurnContext - STREAMING_CHANNELS = [Channels.webchat, Channels.ms_teams, Channels.direct_line] NON_STREAMING_CHANNELS = [Channels.test, Channels.slack, Channels.email] @@ -74,7 +73,9 @@ async def test_queue_informative_update_is_ignored_for_non_streaming_channel(moc @pytest.mark.asyncio -async def test_queue_text_chunk_and_end_stream_send_streaming_then_final_message(mocker): +async def test_queue_text_chunk_and_end_stream_send_streaming_then_final_message( + mocker, +): context = _create_turn_context( mocker, delivery_mode=DeliveryModes.stream, @@ -96,7 +97,9 @@ async def test_queue_text_chunk_and_end_stream_send_streaming_then_final_message @pytest.mark.asyncio -async def test_multiple_queued_text_chunks_are_coalesced_into_one_final_activity(mocker): +async def test_multiple_queued_text_chunks_are_coalesced_into_one_final_activity( + mocker, +): context = _create_turn_context( mocker, delivery_mode=DeliveryModes.stream, @@ -116,7 +119,9 @@ async def test_multiple_queued_text_chunks_are_coalesced_into_one_final_activity @pytest.mark.asyncio -async def test_set_citations_only_sends_final_when_end_stream_happens_before_drain(mocker): +async def test_set_citations_only_sends_final_when_end_stream_happens_before_drain( + mocker, +): context = _create_turn_context( mocker, delivery_mode=DeliveryModes.stream, @@ -145,7 +150,9 @@ async def test_set_citations_only_sends_final_when_end_stream_happens_before_dra @pytest.mark.asyncio -async def test_set_citations_adds_only_used_citations_when_streaming_activity_is_sent(mocker): +async def test_set_citations_adds_only_used_citations_when_streaming_activity_is_sent( + mocker, +): context = _create_turn_context( mocker, delivery_mode=DeliveryModes.stream, @@ -193,12 +200,17 @@ async def test_end_stream_cannot_be_called_twice(mocker): @pytest.mark.asyncio -async def test_teams_403_marks_stream_as_cancelled_and_future_chunks_are_ignored(mocker): +async def test_teams_403_marks_stream_as_cancelled_and_future_chunks_are_ignored( + mocker, +): context = _create_turn_context( mocker, channel_id=Channels.ms_teams, - return_value=[RuntimeError("403 Forbidden: Stream cancelled by user"), ResourceResponse(id="stream-4")], + return_value=[ + RuntimeError("403 Forbidden: Stream cancelled by user"), + ResourceResponse(id="stream-4"), + ], ) response = StreamingResponse(context) @@ -260,7 +272,9 @@ async def test_generated_by_ai_label_adds_ai_entity_on_final_message(mocker): @pytest.mark.asyncio -async def test_streaming_operations_with_sleeps_send_informative_and_text_updates(mocker): +async def test_streaming_operations_with_sleeps_send_informative_and_text_updates( + mocker, +): context = _create_turn_context( mocker, channel_id=Channels.test, @@ -289,7 +303,9 @@ async def test_streaming_operations_with_sleeps_send_informative_and_text_update sent_activities = [call.args[0] for call in context.send_activity.await_args_list] stream_types = [ - next(entity for entity in activity.entities if entity.type == "streaminfo").stream_type + next( + entity for entity in activity.entities if entity.type == "streaminfo" + ).stream_type for activity in sent_activities ] sent_types = [activity.type for activity in sent_activities] @@ -300,7 +316,9 @@ async def test_streaming_operations_with_sleeps_send_informative_and_text_update @pytest.mark.asyncio -async def test_streaming_loop_with_sleep_emits_informative_and_streaming_updates(mocker): +async def test_streaming_loop_with_sleep_emits_informative_and_streaming_updates( + mocker, +): context = _create_turn_context( mocker, channel_id=Channels.test, @@ -332,7 +350,9 @@ async def test_streaming_loop_with_sleep_emits_informative_and_streaming_updates sent_activities = [call.args[0] for call in context.send_activity.await_args_list] stream_types = [ - next(entity for entity in activity.entities if entity.type == "streaminfo").stream_type + next( + entity for entity in activity.entities if entity.type == "streaminfo" + ).stream_type for activity in sent_activities ] @@ -477,7 +497,9 @@ async def test_queue_text_chunk_citations_argument_is_currently_ignored(mocker): @pytest.mark.asyncio -async def test_feedback_loop_type_without_enable_does_not_emit_feedback_loop_object(mocker): +async def test_feedback_loop_type_without_enable_does_not_emit_feedback_loop_object( + mocker, +): context = _create_turn_context( mocker, channel_id=Channels.ms_teams, @@ -491,7 +513,9 @@ async def test_feedback_loop_type_without_enable_does_not_emit_feedback_loop_obj await response.end_stream() sent = context.send_activity.await_args_list[-1].args[0] - streaminfo = next(e for e in sent.entities if getattr(e, "type", None) == "streaminfo") + streaminfo = next( + e for e in sent.entities if getattr(e, "type", None) == "streaminfo" + ) assert not hasattr(streaminfo, "feedback_loop") assert getattr(streaminfo, "feedback_loop_enabled", None) is False From 634b60896f4ef50d2e5fac0dea116b688ccaee60 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 26 Feb 2026 17:06:27 -0800 Subject: [PATCH 06/29] Finalized basic end-to-end streaming tests --- .../testing/core/fluent/activity.py | 404 ------------------ .../microsoft_agents/testing/core/utils.py | 2 +- .../microsoft_agents/testing/utils.py | 2 +- dev/tests/sdk/test_streaming_response.py | 64 ++- 4 files changed, 54 insertions(+), 418 deletions(-) delete mode 100644 dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/activity.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/activity.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/activity.py deleted file mode 100644 index 6d967a5b..00000000 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/activity.py +++ /dev/null @@ -1,404 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -"""Activity-specific fluent utilities. - -This module contains a specialized assertion class (ActivityExpect) for -Activity objects. The implementation is commented out pending finalization -of the API design. -""" - -from __future__ import annotations - -from microsoft_agents.activity import Activity, ActivityTypes - -from typing import Iterable, Self - -# BUG: Duplicate import of Activity — the first import on the line above -# already imports Activity from microsoft_agents.activity. -from microsoft_agents.activity import Activity, ActivityTypes # TODO: Duplicate import of Activity - -from .expect import Expect -from .model_template import ModelTemplate - -# TODO: ActivityExpect is commented out - determine if it should be removed or completed - -# class ActivityExpect(Expect): -# """ -# Specialized Expect class for asserting on Activity objects. - -# Provides convenience methods for common Activity assertions. - -# Usage: -# # Assert all activities are messages -# ActivityExpect(responses).are_messages() - -# # Assert conversation was started -# ActivityExpect(responses).starts_conversation() - -# # Assert text contains value -# ActivityExpect(responses).has_text_containing("hello") -# """ - -# def __init__(self, items: Iterable[Activity]) -> None: -# """Initialize ActivityExpect with Activity objects. - -# :param items: An iterable of Activity instances. -# """ -# super().__init__(items) - -# # ========================================================================= -# # Type Assertions -# # ========================================================================= - -# def are_messages(self) -> Self: -# """Assert that all activities are of type 'message'. - -# :raises AssertionError: If any activity is not a message. -# :return: Self for chaining. -# """ -# return self.that(type=ActivityTypes.message) - -# def are_typing(self) -> Self: -# """Assert that all activities are of type 'typing'. - -# :raises AssertionError: If any activity is not typing. -# :return: Self for chaining. -# """ -# return self.that(type=ActivityTypes.typing) - -# def are_events(self) -> Self: -# """Assert that all activities are of type 'event'. - -# :raises AssertionError: If any activity is not an event. -# :return: Self for chaining. -# """ -# return self.that(type=ActivityTypes.event) - -# def has_type(self, activity_type: str) -> Self: -# """Assert that all activities have the specified type. - -# :param activity_type: The expected activity type. -# :raises AssertionError: If any activity doesn't match the type. -# :return: Self for chaining. -# """ -# return self.that(type=activity_type) - -# def has_any_type(self, activity_type: str) -> Self: -# """Assert that at least one activity has the specified type. - -# :param activity_type: The expected activity type. -# :raises AssertionError: If no activity matches the type. -# :return: Self for chaining. -# """ -# return self.that_for_any(type=activity_type) - -# # ========================================================================= -# # Conversation Flow Assertions -# # ========================================================================= - -# def starts_conversation(self) -> Self: -# """Assert that the activities include a conversation start. - -# Checks for conversationUpdate with membersAdded. - -# :raises AssertionError: If no conversation start activity found. -# :return: Self for chaining. -# """ -# def is_conversation_start(activity: Activity) -> bool: -# if activity.type != ActivityTypes.conversation_update: -# return False -# return bool(activity.members_added and len(activity.members_added) > 0) - -# return self.that_for_any(is_conversation_start) - -# def ends_conversation(self) -> Self: -# """Assert that the activities include a conversation end. - -# Checks for endOfConversation activity type. - -# :raises AssertionError: If no conversation end activity found. -# :return: Self for chaining. -# """ -# return self.that_for_any(type=ActivityTypes.end_of_conversation) - -# def has_members_added(self) -> Self: -# """Assert that at least one activity has members added. - -# :raises AssertionError: If no activity has members added. -# :return: Self for chaining. -# """ -# def has_members(activity: Activity) -> bool: -# return bool(activity.members_added and len(activity.members_added) > 0) - -# return self.that_for_any(has_members) - -# def has_members_removed(self) -> Self: -# """Assert that at least one activity has members removed. - -# :raises AssertionError: If no activity has members removed. -# :return: Self for chaining. -# """ -# def has_removed(activity: Activity) -> bool: -# return bool(activity.members_removed and len(activity.members_removed) > 0) - -# return self.that_for_any(has_removed) - -# # ========================================================================= -# # Text Assertions -# # ========================================================================= - -# def has_text(self, text: str) -> Self: -# """Assert that all activities have the exact text. - -# :param text: The expected text. -# :raises AssertionError: If any activity doesn't have the exact text. -# :return: Self for chaining. -# """ -# return self.that(text=text) - -# def has_any_text(self, text: str) -> Self: -# """Assert that at least one activity has the exact text. - -# :param text: The expected text. -# :raises AssertionError: If no activity has the exact text. -# :return: Self for chaining. -# """ -# return self.that_for_any(text=text) - -# def has_text_containing(self, substring: str) -> Self: -# """Assert that all activities have text containing the substring. - -# :param substring: The substring to search for. -# :raises AssertionError: If any activity doesn't contain the substring. -# :return: Self for chaining. -# """ -# def contains_text(activity: Activity) -> bool: -# return activity.text is not None and substring in activity.text - -# return self.that(contains_text) - -# def has_any_text_containing(self, substring: str) -> Self: -# """Assert that at least one activity has text containing the substring. - -# :param substring: The substring to search for. -# :raises AssertionError: If no activity contains the substring. -# :return: Self for chaining. -# """ -# def contains_text(activity: Activity) -> bool: -# return activity.text is not None and substring in activity.text - -# return self.that_for_any(contains_text) - -# def has_text_matching(self, pattern: str) -> Self: -# """Assert that all activities have text matching the regex pattern. - -# :param pattern: The regex pattern to match. -# :raises AssertionError: If any activity doesn't match the pattern. -# :return: Self for chaining. -# """ -# import re -# regex = re.compile(pattern) - -# def matches_pattern(activity: Activity) -> bool: -# return activity.text is not None and regex.search(activity.text) is not None - -# return self.that(matches_pattern) - -# def has_any_text_matching(self, pattern: str) -> Self: -# """Assert that at least one activity has text matching the regex pattern. - -# :param pattern: The regex pattern to match. -# :raises AssertionError: If no activity matches the pattern. -# :return: Self for chaining. -# """ -# import re -# regex = re.compile(pattern) - -# def matches_pattern(activity: Activity) -> bool: -# return activity.text is not None and regex.search(activity.text) is not None - -# return self.that_for_any(matches_pattern) - -# # ========================================================================= -# # Attachment Assertions -# # ========================================================================= - -# def has_attachments(self) -> Self: -# """Assert that all activities have at least one attachment. - -# :raises AssertionError: If any activity has no attachments. -# :return: Self for chaining. -# """ -# def has_attach(activity: Activity) -> bool: -# return bool(activity.attachments and len(activity.attachments) > 0) - -# return self.that(has_attach) - -# def has_any_attachments(self) -> Self: -# """Assert that at least one activity has attachments. - -# :raises AssertionError: If no activity has attachments. -# :return: Self for chaining. -# """ -# def has_attach(activity: Activity) -> bool: -# return bool(activity.attachments and len(activity.attachments) > 0) - -# return self.that_for_any(has_attach) - -# def has_attachment_of_type(self, content_type: str) -> Self: -# """Assert that at least one activity has an attachment of the specified type. - -# :param content_type: The attachment content type (e.g., 'image/png'). -# :raises AssertionError: If no matching attachment found. -# :return: Self for chaining. -# """ -# def has_type(activity: Activity) -> bool: -# if not activity.attachments: -# return False -# return any(a.content_type == content_type for a in activity.attachments) - -# return self.that_for_any(has_type) - -# def has_adaptive_card(self) -> Self: -# """Assert that at least one activity has an Adaptive Card attachment. - -# :raises AssertionError: If no Adaptive Card found. -# :return: Self for chaining. -# """ -# return self.has_attachment_of_type("application/vnd.microsoft.card.adaptive") - -# def has_hero_card(self) -> Self: -# """Assert that at least one activity has a Hero Card attachment. - -# :raises AssertionError: If no Hero Card found. -# :return: Self for chaining. -# """ -# return self.has_attachment_of_type("application/vnd.microsoft.card.hero") - -# def has_thumbnail_card(self) -> Self: -# """Assert that at least one activity has a Thumbnail Card attachment. - -# :raises AssertionError: If no Thumbnail Card found. -# :return: Self for chaining. -# """ -# return self.has_attachment_of_type("application/vnd.microsoft.card.thumbnail") - -# # ========================================================================= -# # Suggested Actions Assertions -# # ========================================================================= - -# def has_suggested_actions(self) -> Self: -# """Assert that at least one activity has suggested actions. - -# :raises AssertionError: If no activity has suggested actions. -# :return: Self for chaining. -# """ -# def has_actions(activity: Activity) -> bool: -# return bool( -# activity.suggested_actions -# and activity.suggested_actions.actions -# and len(activity.suggested_actions.actions) > 0 -# ) - -# return self.that_for_any(has_actions) - -# def has_suggested_action_titled(self, title: str) -> Self: -# """Assert that at least one activity has a suggested action with the given title. - -# :param title: The expected action title. -# :raises AssertionError: If no matching suggested action found. -# :return: Self for chaining. -# """ -# def has_action_title(activity: Activity) -> bool: -# if not activity.suggested_actions or not activity.suggested_actions.actions: -# return False -# return any(a.title == title for a in activity.suggested_actions.actions) - -# return self.that_for_any(has_action_title) - -# # ========================================================================= -# # Channel/Conversation Assertions -# # ========================================================================= - -# def from_channel(self, channel_id: str) -> Self: -# """Assert that all activities are from the specified channel. - -# :param channel_id: The expected channel ID. -# :raises AssertionError: If any activity is from a different channel. -# :return: Self for chaining. -# """ -# return self.that(channel_id=channel_id) - -# def in_conversation(self, conversation_id: str) -> Self: -# """Assert that all activities are in the specified conversation. - -# :param conversation_id: The expected conversation ID. -# :raises AssertionError: If any activity is in a different conversation. -# :return: Self for chaining. -# """ -# def in_conv(activity: Activity) -> bool: -# return activity.conversation is not None and activity.conversation.id == conversation_id - -# return self.that(in_conv) - -# def from_user(self, user_id: str) -> Self: -# """Assert that all activities are from the specified user. - -# :param user_id: The expected user ID. -# :raises AssertionError: If any activity is from a different user. -# :return: Self for chaining. -# """ -# def from_usr(activity: Activity) -> bool: -# return activity.from_property is not None and activity.from_property.id == user_id - -# return self.that(from_usr) - -# def to_recipient(self, recipient_id: str) -> Self: -# """Assert that all activities are addressed to the specified recipient. - -# :param recipient_id: The expected recipient ID. -# :raises AssertionError: If any activity is to a different recipient. -# :return: Self for chaining. -# """ -# def to_recip(activity: Activity) -> bool: -# return activity.recipient is not None and activity.recipient.id == recipient_id - -# return self.that(to_recip) - -# # ========================================================================= -# # Value/Entity Assertions -# # ========================================================================= - -# def has_value(self) -> Self: -# """Assert that all activities have a value set. - -# :raises AssertionError: If any activity has no value. -# :return: Self for chaining. -# """ -# def has_val(activity: Activity) -> bool: -# return activity.value is not None - -# return self.that(has_val) - -# def has_entities(self) -> Self: -# """Assert that at least one activity has entities. - -# :raises AssertionError: If no activity has entities. -# :return: Self for chaining. -# """ -# def has_ent(activity: Activity) -> bool: -# return bool(activity.entities and len(activity.entities) > 0) - -# return self.that_for_any(has_ent) - -# def has_semantic_action(self) -> Self: -# """Assert that at least one activity has a semantic action. - -# :raises AssertionError: If no activity has a semantic action. -# :return: Self for chaining. -# """ -# def has_action(activity: Activity) -> bool: -# return activity.semantic_action is not None - -# return self.that_for_any(has_action) \ No newline at end of file diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/utils.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/utils.py index a704737c..f8223976 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/utils.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/utils.py @@ -90,4 +90,4 @@ def generate_token_from_config(sdk_config: dict, connection_name: str = "SERVICE if not client_id or not client_secret or not tenant_id: raise ValueError("Incorrect configuration provided for token generation.") - return generate_token(client_id, client_secret, tenant_id) + return generate_token(client_id, client_secret, tenant_id) \ No newline at end of file diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/utils.py b/dev/microsoft-agents-testing/microsoft_agents/testing/utils.py index 4d984d6c..f89965dd 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/utils.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/utils.py @@ -102,4 +102,4 @@ def resolve_scenario(scenario_or_str: Scenario | str ) -> Scenario: else: return scenario_registry.get(scenario_or_str) else: - raise TypeError("Input must be a Scenario instance or a string key.") \ No newline at end of file + raise TypeError("Input must be a Scenario instance or a string key.") diff --git a/dev/tests/sdk/test_streaming_response.py b/dev/tests/sdk/test_streaming_response.py index 669c584c..d73ad022 100644 --- a/dev/tests/sdk/test_streaming_response.py +++ b/dev/tests/sdk/test_streaming_response.py @@ -1,7 +1,12 @@ import pytest import asyncio -from microsoft_agents.activity import ActivityTypes +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + Channels, + Entity +) from microsoft_agents.hosting.core import ( TurnContext, @@ -17,6 +22,14 @@ FULL_TEXT = "This is a streaming response." CHUNKS = FULL_TEXT.split() +def get_streaminfo(activity: Activity) -> Entity: + for entity in activity.entities: + if isinstance(entity, dict) and entity.get("type") == "streaminfo": + return Entity.model_validate(entity) + elif isinstance(entity, Entity) and entity.type == "streaminfo": + return entity + raise ValueError("No streaminfo entity found") + async def init_agent(env: AgentEnvironment): app = env.agent_application @@ -40,24 +53,51 @@ async def stream_handler(context: TurnContext, state: TurnState): @pytest.mark.asyncio @pytest.mark.agent_test(_SCENARIO) -async def test_basic_streaming_response(agent_client: AgentClient): +async def test_basic_streaming_response_non_streaming_channel(agent_client: AgentClient): + + expected_len = len(FULL_TEXT.split()) + + agent_client.template = agent_client.template.with_updates(channel_id=Channels.emulator) + + # give enough time for all the activities to send + await agent_client.send("/stream", wait=expected_len * 2.0) + + stream_activities = agent_client.select().where( + entities=lambda x: any(e["type"] == "streaminfo" for e in x) + ).get() + + assert len(stream_activities) == 1 + + final_streaminfo = get_streaminfo(stream_activities[0]) + + assert final_streaminfo.stream_sequence == 1 + assert final_streaminfo.stream_type == "final" + assert stream_activities[0].text == FULL_TEXT.replace(" ", "") + + + +@pytest.mark.asyncio +@pytest.mark.agent_test(_SCENARIO) +async def test_basic_streaming_response_streaming_channel(agent_client: AgentClient): expected_len = len(FULL_TEXT.split()) + agent_client.template = agent_client.template.with_updates(channel_id=Channels.webchat) + # give enough time for all the activities to send await agent_client.send("/stream", wait=expected_len * 2.0) stream_activities = agent_client.select().where( - entities=lambda x: any(e.type == "streaminfo" for e in x) - ) + entities=lambda x: any(e["type"] == "streaminfo" for e in x) + ).get() assert len(stream_activities) == len(CHUNKS) + 2 informative = stream_activities[0] - informative_streaminfo = informative[0].entities.first(lambda e: e.type == "streaminfo") + informative_streaminfo = get_streaminfo(informative) assert informative_streaminfo.stream_type == "informative" - assert informative_streaminfo.stream_sequence == 0 + assert informative_streaminfo.stream_sequence == 1 assert informative.text == "Starting stream..." assert informative.type == ActivityTypes.typing @@ -67,16 +107,16 @@ async def test_basic_streaming_response(agent_client: AgentClient): j = i + 1 - streaminfo = stream_activities[j].entities.first(lambda e: e.type == "streaminfo") + streaminfo = get_streaminfo(stream_activities[j]) - assert stream_activities[j].text == "" + assert stream_activities[j].text == t assert stream_activities[j].type == ActivityTypes.typing assert streaminfo.stream_type == "streaming" - assert streaminfo.stream_sequence == j + assert streaminfo.stream_sequence == j + 1 - final_streaminfo = stream_activities[-1].entities.first(lambda e: e.type == "streaminfo") + final_streaminfo = get_streaminfo(stream_activities[-1]) - assert final_streaminfo.stream_sequence == len(CHUNKS) + 2 + assert final_streaminfo.stream_sequence == len(stream_activities) assert final_streaminfo.stream_type == "final" - assert stream_activities[-1].text == FULL_TEXT + assert stream_activities[-1].text == FULL_TEXT.replace(" ", "") From 13dda2adef3632a15c82aa778e1009ebc61ec520 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 27 Feb 2026 10:27:39 -0800 Subject: [PATCH 07/29] Addressing Copilot PR review --- .../hosting/core/app/streaming/streaming_response.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py index cde42b72..3b23e48a 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py @@ -4,8 +4,7 @@ import uuid import asyncio import logging -from typing import List, Optional, Callable, Literal, TYPE_CHECKING -from dataclasses import dataclass +from typing import List, Optional, Callable, Literal from microsoft_agents.activity import ( Activity, @@ -325,7 +324,7 @@ async def _drain_queue(self) -> None: except Exception as err: if ( "403" in str(err) - and self._context.activity.channel_id == Channels.ms_teams + and self._context.activity.channel_id.channel == Channels.ms_teams ): logger.warning("Teams channel stopped the stream.") self._cancelled = True From c1218344a902f8f41122a6a55fabe37535c322fa Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 27 Feb 2026 10:39:49 -0800 Subject: [PATCH 08/29] Validating channel_id before accessing parent channel --- .../hosting/core/app/streaming/streaming_response.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py index 3b23e48a..03180eca 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py @@ -322,8 +322,8 @@ async def _drain_queue(self) -> None: if activity: await self._send_activity(activity) except Exception as err: - if ( - "403" in str(err) + if "403" in str(err) and ( + self._context.activity.channel_id is not None and self._context.activity.channel_id.channel == Channels.ms_teams ): logger.warning("Teams channel stopped the stream.") From a230b9a5e8cc3faed6c4eee043ef0bc771d80395 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 2 Mar 2026 12:03:55 -0800 Subject: [PATCH 09/29] Removing duplicate sample --- dev/tests/sdk/test_streaming_response.py | 12 ++++++------ .../app_style/{emtpy_agent.py => empty_agent.py} | 0 2 files changed, 6 insertions(+), 6 deletions(-) rename test_samples/app_style/{emtpy_agent.py => empty_agent.py} (100%) diff --git a/dev/tests/sdk/test_streaming_response.py b/dev/tests/sdk/test_streaming_response.py index d73ad022..48f49366 100644 --- a/dev/tests/sdk/test_streaming_response.py +++ b/dev/tests/sdk/test_streaming_response.py @@ -40,13 +40,13 @@ async def stream_handler(context: TurnContext, state: TurnState): assert context.streaming_response is not None context.streaming_response.queue_informative_update("Starting stream...") + await asyncio.sleep(1.0) # Simulate delay before starting stream - for chunk in CHUNKS: - await asyncio.sleep(1.0) # Simulate delay between chunks + for chunk in CHUNKS[:-1]: context.streaming_response.queue_text_chunk(chunk) + await asyncio.sleep(1.0) # Simulate delay between chunks - await asyncio.sleep(1.0) - + context.streaming_response.queue_text_chunk(CHUNKS[-1]) await context.streaming_response.end_stream() _SCENARIO = AiohttpScenario(init_agent=init_agent, use_jwt_middleware=False) @@ -91,7 +91,7 @@ async def test_basic_streaming_response_streaming_channel(agent_client: AgentCli entities=lambda x: any(e["type"] == "streaminfo" for e in x) ).get() - assert len(stream_activities) == len(CHUNKS) + 2 + assert len(stream_activities) == len(CHUNKS) + 1 informative = stream_activities[0] informative_streaminfo = get_streaminfo(informative) @@ -102,7 +102,7 @@ async def test_basic_streaming_response_streaming_channel(agent_client: AgentCli assert informative.type == ActivityTypes.typing t = "" - for i, chunk in enumerate(CHUNKS): + for i, chunk in enumerate(CHUNKS[:-1]): t += chunk j = i + 1 diff --git a/test_samples/app_style/emtpy_agent.py b/test_samples/app_style/empty_agent.py similarity index 100% rename from test_samples/app_style/emtpy_agent.py rename to test_samples/app_style/empty_agent.py From ee5016c555f86ce1ff9664a44720d5f205327886 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 2 Mar 2026 14:18:23 -0800 Subject: [PATCH 10/29] Another commit --- .../hosting/core/app/streaming/streaming_response.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py index 03180eca..34814046 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py @@ -269,7 +269,7 @@ def create_activity() -> Activity | None: if self._ended: # Send final message activity = Activity( - type="message", + type="message", text=self._message or "end stream response", attachments=self._attachments or [], entities=[ From 86686aec45d35a1f3a9c282f3e69bee08d089959 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 3 Mar 2026 08:36:52 -0800 Subject: [PATCH 11/29] Adding '@type' aliases for AIEntity-related classes --- .../microsoft_agents/activity/agents_model.py | 2 +- .../activity/entity/ai_entity.py | 17 ++++++----- .../core/app/streaming/streaming_response.py | 29 ++++++++++++------- 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/agents_model.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/agents_model.py index 3f937b58..13d409d0 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/agents_model.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/agents_model.py @@ -8,7 +8,7 @@ class AgentsModel(BaseModel): - model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + model_config = ConfigDict(alias_generator=to_camel, validate_by_name=True, validate_by_alias=True) """ @model_serializer diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py index 5995867e..83e15d8b 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py @@ -2,8 +2,9 @@ # Licensed under the MIT License. from enum import Enum -from typing import List, Optional, Union, Literal -from dataclasses import dataclass +from typing import List, Optional + +from pydantic import Field from ..agents_model import AgentsModel from .entity import Entity @@ -45,7 +46,7 @@ class ClientCitationImage(AgentsModel): class SensitivityPattern(AgentsModel): """Pattern information for sensitivity usage info.""" - type: str = "DefinedTerm" + type: str = Field("DefinedTerm", alias="@type") in_defined_term_set: str = "" name: str = "" term_code: str = "" @@ -58,7 +59,7 @@ class SensitivityUsageInfo(AgentsModel): """ type: str = "https://schema.org/Message" - schema_type: str = "CreativeWork" + schema_type: str = Field("CreativeWork", alias="@type") description: Optional[str] = None name: str = "" position: Optional[int] = None @@ -68,7 +69,7 @@ class SensitivityUsageInfo(AgentsModel): class ClientCitationAppearance(AgentsModel): """Appearance information for a client citation.""" - type: str = "DigitalDocument" + type: str = Field("DigitalDocument", alias="@type") name: str = "" text: Optional[str] = None url: Optional[str] = None @@ -86,7 +87,7 @@ class ClientCitation(AgentsModel): https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bot-messages-ai-generated-content?tabs=before%2Cbotmessage """ - type: str = "Claim" + type: str = Field("Claim", alias="@type") position: int = 0 appearance: Optional[ClientCitationAppearance] = None @@ -99,8 +100,8 @@ class AIEntity(Entity): """Entity indicating AI-generated content.""" type: str = "https://schema.org/Message" - schema_type: str = "Message" - context: str = "https://schema.org" + schema_type: str = Field("Message", alias="@type") + context: str = Field("https://schema.org", alias="@context") id: str = "" additional_type: Optional[List[str]] = None citation: Optional[List[ClientCitation]] = None diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py index 34814046..aa01896a 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py @@ -8,12 +8,14 @@ from microsoft_agents.activity import ( Activity, + AIEntity, Entity, Attachment, Channels, ClientCitation, DeliveryModes, SensitivityUsageInfo, + ClientCitationAppearance, ) from microsoft_agents.hosting.core.errors import error_resources @@ -55,7 +57,7 @@ def __init__(self, context: "TurnContext"): self._is_streaming_channel = False self._interval = 0.1 self._attachments: Optional[List[Attachment]] = None - self._citations: Optional[List[ClientCitation]] = None + self._citations: List[ClientCitation] = [] self._sensitivity_label: Optional[SensitivityUsageInfo] = None self._enable_feedback_loop = False self._feedback_loop_type: Optional[Literal["default", "custom"]] = None @@ -174,11 +176,12 @@ def set_citations(self, citations: List[Citation]) -> None: client_citation = ClientCitation( type="Claim", position=curr_pos + 1, - appearance={ - "type": "DigitalDocument", - "name": citation.title or f"Document #{curr_pos + 1}", - "abstract": CitationUtil.snippet(citation.content, 477), - }, + appearance=ClientCitationAppearance( + type="DigitalDocument", + name=citation.title or f"Document #{curr_pos + 1}", + abstract=CitationUtil.snippet(citation.content, 480), + url=citation.url, + ) ) curr_pos += 1 self._citations.append(client_citation) @@ -365,17 +368,17 @@ async def _send_activity(self, activity: Activity) -> None: activity.id = self._stream_id streaminfo_entity.stream_id = self._stream_id - if self._citations and not self._ended: + # the activity.add_ai_metadata call further down will add citations. + # The extra condition here is to avoid duplication + if self._citations and not self._ended and not self._enable_generated_by_ai_label: # Filter out the citations unused in content. curr_citations = CitationUtil.get_used_citations( self._message, self._citations ) if curr_citations: activity.entities.append( - Entity( + AIEntity( type="https://schema.org/Message", - schema_type="Message", - context="https://schema.org", id="", citation=curr_citations, ) @@ -391,9 +394,13 @@ async def _send_activity(self, activity: Activity) -> None: streaminfo_entity.feedback_loop_enabled = self._enable_feedback_loop # Add in Generated by AI if self._enable_generated_by_ai_label: - activity.add_ai_metadata(self._citations, self._sensitivity_label) + curr_citations = CitationUtil.get_used_citations( + self._message, self._citations + ) + activity.add_ai_metadata(curr_citations, self._sensitivity_label) # Send activity + breakpoint() response = await self._context.send_activity(activity) await asyncio.sleep(self._interval) From a6a58f893f2a7f1078c29bf821f808bf8bf2aec2 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 3 Mar 2026 09:11:45 -0800 Subject: [PATCH 12/29] Fixing further linting issues --- .../microsoft_agents/activity/__init__.py | 3 +++ .../microsoft_agents/activity/activity.py | 4 ++- .../activity/entity/__init__.py | 2 ++ .../activity/entity/ai_entity.py | 13 +++++++-- .../activity/entity/entity_types.py | 1 + .../activity/entity/stream_info.py | 21 +++++++++++++++ .../core/app/streaming/streaming_response.py | 27 +++++++++---------- 7 files changed, 54 insertions(+), 17 deletions(-) create mode 100644 libraries/microsoft-agents-activity/microsoft_agents/activity/entity/stream_info.py diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py index 7311abf2..e4edc525 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py @@ -44,6 +44,7 @@ Place, ProductInfo, Thing, + StreamInfo, ) from .error import Error from .error_response import ErrorResponse @@ -133,6 +134,7 @@ "ExpectedReplies", "Entity", "AIEntity", + "EntityTypes", "ClientCitation", "ClientCitationAppearance", "ClientCitationImage", @@ -154,6 +156,7 @@ "OAuthCard", "PagedMembersResult", "Place", + "StreamInfo", "ProductInfo", "ReceiptCard", "ReceiptItem", diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index 80a24363..2b36f581 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -157,7 +157,7 @@ class Activity(AgentsModel, _ChannelIdFieldMixin): local_timestamp: datetime = None local_timezone: NonEmptyString = None service_url: NonEmptyString = None - from_property: ChannelAccount = Field(None, alias="from") + from_property: ChannelAccount = Field(None, validation_alias="from", serialization_alias="from") conversation: ConversationAccount = None recipient: ChannelAccount = None text_format: NonEmptyString = None @@ -193,6 +193,8 @@ class Activity(AgentsModel, _ChannelIdFieldMixin): semantic_action: SemanticAction = None caller_id: NonEmptyString = None + def __init__(self, *, from_property: ChannelAccount | None = ..., **data: Any) -> None: ... + @model_validator(mode="wrap") @classmethod def _validate_channel_id( diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/__init__.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/__init__.py index 42fe69fd..32345420 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/__init__.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/__init__.py @@ -16,6 +16,7 @@ from .geo_coordinates import GeoCoordinates from .place import Place from .product_info import ProductInfo +from .stream_info import StreamInfo from .thing import Thing __all__ = [ @@ -29,6 +30,7 @@ "Mention", "SensitivityUsageInfo", "SensitivityPattern", + "StreamInfo", "GeoCoordinates", "Place", "ProductInfo", diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py index 83e15d8b..ca39a930 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py @@ -79,6 +79,9 @@ class ClientCitationAppearance(AgentsModel): keywords: Optional[List[str]] = None usage_info: Optional[SensitivityUsageInfo] = None + def __init__(self, **data): # removes linter errors for user-facing code + super().__init__(**data) + class ClientCitation(AgentsModel): """ @@ -91,6 +94,9 @@ class ClientCitation(AgentsModel): position: int = 0 appearance: Optional[ClientCitationAppearance] = None + def __init__(self, **data): # removes linter errors for user-facing code + super().__init__(**data) + def __post_init__(self): if self.appearance is None: self.appearance = ClientCitationAppearance() @@ -100,13 +106,16 @@ class AIEntity(Entity): """Entity indicating AI-generated content.""" type: str = "https://schema.org/Message" - schema_type: str = Field("Message", alias="@type") - context: str = Field("https://schema.org", alias="@context") + schema_type: str = Field("Message", validation_alias="@type", serialization_alias="@type") + context: str = Field("https://schema.org", validation_alias="@context", serialization_alias="@context") id: str = "" additional_type: Optional[List[str]] = None citation: Optional[List[ClientCitation]] = None usage_info: Optional[SensitivityUsageInfo] = None + def __init__(self, **data): # removes linter errors for user-facing code + super().__init__(**data) + def __post_init__(self): if self.additional_type is None: self.additional_type = ["AIGeneratedContent"] diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity_types.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity_types.py index 4af74397..0cb3da0f 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity_types.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity_types.py @@ -12,3 +12,4 @@ class EntityTypes(str, Enum): PLACE = "Place" THING = "Thing" PRODUCT_INFO = "ProductInfo" + STREAM_INFO = "streaminfo" diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/stream_info.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/stream_info.py new file mode 100644 index 00000000..c320e1e6 --- /dev/null +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/stream_info.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Literal + +from .._type_aliases import NonEmptyString +from .entity import Entity +from .entity_types import EntityTypes + +class StreamInfo(Entity): + + type: Literal[EntityTypes.STREAM_INFO] = EntityTypes.STREAM_INFO + + stream_type: NonEmptyString = "streaming" + stream_sequence: int + + stream_id: str = "" + stream_result: str = "" + + feedback_loop_enabled: bool = False + feedback_loop: dict | None = None \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py index aa01896a..039b9ebc 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py @@ -4,18 +4,20 @@ import uuid import asyncio import logging -from typing import List, Optional, Callable, Literal +from typing import List, Optional, Callable, Literal, cast from microsoft_agents.activity import ( Activity, AIEntity, Entity, + EntityTypes, Attachment, Channels, ClientCitation, DeliveryModes, SensitivityUsageInfo, ClientCitationAppearance, + StreamInfo, ) from microsoft_agents.hosting.core.errors import error_resources @@ -88,8 +90,7 @@ def create_activity(): type="typing", text=text, entities=[ - Entity( - type="streaminfo", + StreamInfo( stream_type="informative", stream_sequence=self._sequence_number, ) @@ -174,10 +175,8 @@ def set_citations(self, citations: List[Citation]) -> None: for citation in citations: client_citation = ClientCitation( - type="Claim", position=curr_pos + 1, appearance=ClientCitationAppearance( - type="DigitalDocument", name=citation.title or f"Document #{curr_pos + 1}", abstract=CitationUtil.snippet(citation.content, 480), url=citation.url, @@ -276,8 +275,7 @@ def create_activity() -> Activity | None: text=self._message or "end stream response", attachments=self._attachments or [], entities=[ - Entity( - type="streaminfo", + StreamInfo( stream_type="final", stream_sequence=self._sequence_number, ) @@ -289,8 +287,7 @@ def create_activity() -> Activity | None: type="typing", text=self._message, entities=[ - Entity( - type="streaminfo", + StreamInfo( stream_type="streaming", stream_sequence=self._sequence_number, ) @@ -347,20 +344,22 @@ async def _send_activity(self, activity: Activity) -> None: activity: The activity to send. """ - streaminfo_entity = None + streaminfo_entity: StreamInfo | None = None if not activity.entities: - streaminfo_entity = Entity(type="streaminfo") + streaminfo_entity = StreamInfo(stream_sequence=self._sequence_number) + self._sequence_number += 1 activity.entities = [streaminfo_entity] else: for entity in activity.entities: - if hasattr(entity, "type") and entity.type == "streaminfo": - streaminfo_entity = entity + if entity.type == EntityTypes.STREAM_INFO: + streaminfo_entity = cast(StreamInfo, entity) break if not streaminfo_entity: # If no streaminfo entity exists, create one - streaminfo_entity = Entity(type="streaminfo") + streaminfo_entity = StreamInfo(stream_sequence=self._sequence_number) + self._sequence_number += 1 activity.entities.append(streaminfo_entity) # Set activity ID to the assigned stream ID From df23e5c3d3fa5f8f08cfab72b840862deddd49a5 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 3 Mar 2026 09:42:01 -0800 Subject: [PATCH 13/29] Removing exclude_unset=True usagei n reply_to_activity to enable proper serialization of streaming entities --- .../microsoft_agents/activity/activity.py | 7 +++---- .../activity/entity/ai_entity.py | 4 +++- .../activity/entity/entity.py | 19 ++----------------- .../activity/entity/stream_info.py | 2 +- .../core/app/streaming/streaming_response.py | 1 - .../core/connector/client/connector_client.py | 10 +++++----- 6 files changed, 14 insertions(+), 29 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index 2b36f581..43ad780e 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -193,7 +193,9 @@ class Activity(AgentsModel, _ChannelIdFieldMixin): semantic_action: SemanticAction = None caller_id: NonEmptyString = None - def __init__(self, *, from_property: ChannelAccount | None = ..., **data: Any) -> None: ... + # fixes user-facing linting issues + def __init__(self, *, from_property: ChannelAccount | None = None, **data: Any) -> None: + super().__init__(**data) @model_validator(mode="wrap") @classmethod @@ -756,9 +758,6 @@ def add_ai_metadata( """ if citations: ai_entity = AIEntity( - type="https://schema.org/Message", - schema_type="Message", - context="https://schema.org", id="", additional_type=["AIGeneratedContent"], citation=citations, diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py index ca39a930..9681aa60 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py @@ -65,6 +65,8 @@ class SensitivityUsageInfo(AgentsModel): position: Optional[int] = None pattern: Optional[SensitivityPattern] = None + def __init__(self, **data): # removes linter errors for user-facing code + super().__init__(**data) class ClientCitationAppearance(AgentsModel): """Appearance information for a client citation.""" @@ -118,4 +120,4 @@ def __init__(self, **data): # removes linter errors for user-facing code def __post_init__(self): if self.additional_type is None: - self.additional_type = ["AIGeneratedContent"] + self.additional_type = ["AIGeneratedContent"] \ No newline at end of file diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py index 74b35142..e90e0970 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py @@ -16,26 +16,11 @@ class Entity(AgentsModel): :type type: str """ - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra="allow", alias_generator=to_camel, validate_by_name=True, validate_by_alias=True) type: str @property def additional_properties(self) -> dict[str, Any]: """Returns the set of properties that are not None.""" - return self.model_extra - - @model_validator(mode="before") - @classmethod - def to_snake_for_all(cls, data): - ret = {to_snake(k): v for k, v in data.items()} - return ret - - @model_serializer(mode="plain") - def to_camel_for_all(self, config): - if config.by_alias: - new_data = {} - for k, v in self: - new_data[to_camel(k)] = v - return new_data - return {k: v for k, v in self} + return self.model_extra \ No newline at end of file diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/stream_info.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/stream_info.py index c320e1e6..adc8c06e 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/stream_info.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/stream_info.py @@ -9,7 +9,7 @@ class StreamInfo(Entity): - type: Literal[EntityTypes.STREAM_INFO] = EntityTypes.STREAM_INFO + type: str = EntityTypes.STREAM_INFO.value stream_type: NonEmptyString = "streaming" stream_sequence: int diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py index 039b9ebc..2f5f6c56 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py @@ -399,7 +399,6 @@ async def _send_activity(self, activity: Activity) -> None: activity.add_ai_metadata(curr_citations, self._sensitivity_label) # Send activity - breakpoint() response = await self._context.send_activity(activity) await asyncio.sleep(self._interval) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py index 8737e8a3..668ffd12 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py @@ -171,7 +171,7 @@ async def create_conversation( logger.info("Creating a new conversation") async with self.client.post( "v3/conversations", - json=body.model_dump(by_alias=True, exclude_unset=True, mode="json"), + json=body.model_dump(by_alias=True, mode="json"), ) as response: if response.status >= 300: logger.error( @@ -209,11 +209,11 @@ async def reply_to_activity( conversation_id, body.type, ) - + async with self.client.post( url, json=body.model_dump( - by_alias=True, exclude_unset=True, exclude_none=True, mode="json" + by_alias=True, exclude_none=True, mode="json" ), ) as response: @@ -267,7 +267,7 @@ async def send_to_conversation( ) async with self.client.post( url, - json=body.model_dump(by_alias=True, exclude_unset=True, mode="json"), + json=body.model_dump(by_alias=True, mode="json"), ) as response: if response.status >= 300: logger.error( @@ -311,7 +311,7 @@ async def update_activity( ) async with self.client.put( url, - json=body.model_dump(by_alias=True, exclude_unset=True), + json=body.model_dump(by_alias=True), ) as response: if response.status >= 300: logger.error( From 904557fa64fd1a7eeeaf3b1714f6476e2e1821f6 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 17 Mar 2026 15:42:45 -0700 Subject: [PATCH 14/29] Adding improved serialization for AIEntity and related classes --- .../activity/entity/_schema_mixin.py | 19 +++++++++ .../activity/entity/ai_entity.py | 33 +++++++++------- tests/activity/entity/__init__.py | 0 tests/activity/entity/test_serialization.py | 39 +++++++++++++++++++ 4 files changed, 76 insertions(+), 15 deletions(-) create mode 100644 libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py create mode 100644 tests/activity/entity/__init__.py create mode 100644 tests/activity/entity/test_serialization.py diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py new file mode 100644 index 00000000..6725d7ed --- /dev/null +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Any +from pydantic import model_serializer, SerializerFunctionWrapHandler + +class _SchemaMixin: + + at_type: Any + + @model_serializer(mode="wrap") + def serialize_model( + self, handler: SerializerFunctionWrapHandler + ) -> dict[str, object]: + serialized = handler(self) + serialized["@type"] = self.at_type + if hasattr(self, "@context"): + serialized['@context'] = getattr(self, "at_context") + return serialized \ No newline at end of file diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py index 9681aa60..aa5631dd 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py @@ -2,14 +2,12 @@ # Licensed under the MIT License. from enum import Enum -from typing import List, Optional - -from pydantic import Field +from typing import List, Optional, Literal from ..agents_model import AgentsModel +from ._schema_mixin import _SchemaMixin from .entity import Entity - class ClientCitationIconName(str, Enum): """Enumeration of supported citation icon names.""" @@ -43,23 +41,25 @@ class ClientCitationImage(AgentsModel): name: str = "" -class SensitivityPattern(AgentsModel): +class SensitivityPattern(AgentsModel, _SchemaMixin): """Pattern information for sensitivity usage info.""" - type: str = Field("DefinedTerm", alias="@type") + at_type: Literal["DefinedTerm"] = "DefinedTerm" + in_defined_term_set: str = "" name: str = "" term_code: str = "" -class SensitivityUsageInfo(AgentsModel): +class SensitivityUsageInfo(AgentsModel, _SchemaMixin): """ Sensitivity usage info for content sent to the user. This is used to provide information about the content to the user. """ type: str = "https://schema.org/Message" - schema_type: str = Field("CreativeWork", alias="@type") + at_type: Literal["CreativeWork"] = "CreativeWork" + description: Optional[str] = None name: str = "" position: Optional[int] = None @@ -68,10 +68,11 @@ class SensitivityUsageInfo(AgentsModel): def __init__(self, **data): # removes linter errors for user-facing code super().__init__(**data) -class ClientCitationAppearance(AgentsModel): +class ClientCitationAppearance(AgentsModel, _SchemaMixin): """Appearance information for a client citation.""" - type: str = Field("DigitalDocument", alias="@type") + at_type: Literal["DigitalDocument"] = "DigitalDocument" + name: str = "" text: Optional[str] = None url: Optional[str] = None @@ -85,14 +86,15 @@ def __init__(self, **data): # removes linter errors for user-facing code super().__init__(**data) -class ClientCitation(AgentsModel): +class ClientCitation(AgentsModel, _SchemaMixin): """ Represents a Teams client citation to be included in a message. See Bot messages with AI-generated content for more details. https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bot-messages-ai-generated-content?tabs=before%2Cbotmessage """ - type: str = Field("Claim", alias="@type") + at_type: Literal["Claim"] = "Claim" + position: int = 0 appearance: Optional[ClientCitationAppearance] = None @@ -104,12 +106,13 @@ def __post_init__(self): self.appearance = ClientCitationAppearance() -class AIEntity(Entity): +class AIEntity(Entity, _SchemaMixin): """Entity indicating AI-generated content.""" + at_type: Literal["Message"] = "Message" + at_context: Literal["https://schema.org"] = "https://schema.org" + type: str = "https://schema.org/Message" - schema_type: str = Field("Message", validation_alias="@type", serialization_alias="@type") - context: str = Field("https://schema.org", validation_alias="@context", serialization_alias="@context") id: str = "" additional_type: Optional[List[str]] = None citation: Optional[List[ClientCitation]] = None diff --git a/tests/activity/entity/__init__.py b/tests/activity/entity/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/activity/entity/test_serialization.py b/tests/activity/entity/test_serialization.py new file mode 100644 index 00000000..5498bd9b --- /dev/null +++ b/tests/activity/entity/test_serialization.py @@ -0,0 +1,39 @@ +import pytest + +from microsoft_agents.activity.entity import ( + AIEntity, + ClientCitation, + ClientCitationAppearance, + SensitivityPattern, + SensitivityUsageInfo, +) + +@pytest.mark.parametrize( + "entity_cls" + [ + ClientCitation, + SensitivityUsageInfo, + ClientCitationAppearance, + SensitivityPattern + ] +) +def test_schema_mixin_at_type_serialization(entity_cls): + + expected = entity_cls.at_type + assert isinstance(expected, str) and expected != "" + + entity = entity_cls() + + data = entity.model_dump(exclude_unset=True) + + assert "@type" in data + assert data["@type"] == expected + +def test_schema_mixin_at_context_serialization(): + + ai_entity = AIEntity() + + data = ai_entity.model_dump(exclude_unset=True) + + assert data["@type"] == AIEntity.at_type + assert data["@context"] == AIEntity.at_context \ No newline at end of file From bb2980c076b0762293b534070e2e5b2afa3b8568 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 18 Mar 2026 13:34:56 -0700 Subject: [PATCH 15/29] Readded exclude_unset=True usage in ConversationsOperations --- .../hosting/core/connector/client/connector_client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py index 668ffd12..5dc5e8ad 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py @@ -171,7 +171,7 @@ async def create_conversation( logger.info("Creating a new conversation") async with self.client.post( "v3/conversations", - json=body.model_dump(by_alias=True, mode="json"), + json=body.model_dump(by_alias=True, exclude_unset=True, mode="json"), ) as response: if response.status >= 300: logger.error( @@ -213,7 +213,7 @@ async def reply_to_activity( async with self.client.post( url, json=body.model_dump( - by_alias=True, exclude_none=True, mode="json" + by_alias=True, exclude_unset=True, exclude_none=True, mode="json" ), ) as response: @@ -267,7 +267,7 @@ async def send_to_conversation( ) async with self.client.post( url, - json=body.model_dump(by_alias=True, mode="json"), + json=body.model_dump(by_alias=True, exclude_unset=True, mode="json"), ) as response: if response.status >= 300: logger.error( @@ -311,7 +311,7 @@ async def update_activity( ) async with self.client.put( url, - json=body.model_dump(by_alias=True), + json=body.model_dump(exclude_unset=True, by_alias=True), ) as response: if response.status >= 300: logger.error( From b976eb98e125a9ad444b7c50f7edb59bdae32f8b Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 18 Mar 2026 13:36:45 -0700 Subject: [PATCH 16/29] Formatting --- .../microsoft_agents/activity/activity.py | 8 ++++++-- .../microsoft_agents/activity/agents_model.py | 4 +++- .../activity/entity/_schema_mixin.py | 5 +++-- .../microsoft_agents/activity/entity/ai_entity.py | 12 +++++++----- .../microsoft_agents/activity/entity/entity.py | 9 +++++++-- .../microsoft_agents/activity/entity/stream_info.py | 5 +++-- .../hosting/core/app/streaming/streaming_response.py | 10 +++++++--- .../core/connector/client/connector_client.py | 2 +- tests/activity/entity/test_serialization.py | 9 +++++---- tests/copilotstudio_client/test_copilot_client.py | 1 + 10 files changed, 43 insertions(+), 22 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index 43ad780e..a495bba6 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -157,7 +157,9 @@ class Activity(AgentsModel, _ChannelIdFieldMixin): local_timestamp: datetime = None local_timezone: NonEmptyString = None service_url: NonEmptyString = None - from_property: ChannelAccount = Field(None, validation_alias="from", serialization_alias="from") + from_property: ChannelAccount = Field( + None, validation_alias="from", serialization_alias="from" + ) conversation: ConversationAccount = None recipient: ChannelAccount = None text_format: NonEmptyString = None @@ -194,7 +196,9 @@ class Activity(AgentsModel, _ChannelIdFieldMixin): caller_id: NonEmptyString = None # fixes user-facing linting issues - def __init__(self, *, from_property: ChannelAccount | None = None, **data: Any) -> None: + def __init__( + self, *, from_property: ChannelAccount | None = None, **data: Any + ) -> None: super().__init__(**data) @model_validator(mode="wrap") diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/agents_model.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/agents_model.py index 13d409d0..a7465ed3 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/agents_model.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/agents_model.py @@ -8,7 +8,9 @@ class AgentsModel(BaseModel): - model_config = ConfigDict(alias_generator=to_camel, validate_by_name=True, validate_by_alias=True) + model_config = ConfigDict( + alias_generator=to_camel, validate_by_name=True, validate_by_alias=True + ) """ @model_serializer diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py index 6725d7ed..805f105f 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py @@ -4,6 +4,7 @@ from typing import Any from pydantic import model_serializer, SerializerFunctionWrapHandler + class _SchemaMixin: at_type: Any @@ -15,5 +16,5 @@ def serialize_model( serialized = handler(self) serialized["@type"] = self.at_type if hasattr(self, "@context"): - serialized['@context'] = getattr(self, "at_context") - return serialized \ No newline at end of file + serialized["@context"] = getattr(self, "at_context") + return serialized diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py index aa5631dd..c31daa70 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py @@ -8,6 +8,7 @@ from ._schema_mixin import _SchemaMixin from .entity import Entity + class ClientCitationIconName(str, Enum): """Enumeration of supported citation icon names.""" @@ -65,9 +66,10 @@ class SensitivityUsageInfo(AgentsModel, _SchemaMixin): position: Optional[int] = None pattern: Optional[SensitivityPattern] = None - def __init__(self, **data): # removes linter errors for user-facing code + def __init__(self, **data): # removes linter errors for user-facing code super().__init__(**data) + class ClientCitationAppearance(AgentsModel, _SchemaMixin): """Appearance information for a client citation.""" @@ -82,7 +84,7 @@ class ClientCitationAppearance(AgentsModel, _SchemaMixin): keywords: Optional[List[str]] = None usage_info: Optional[SensitivityUsageInfo] = None - def __init__(self, **data): # removes linter errors for user-facing code + def __init__(self, **data): # removes linter errors for user-facing code super().__init__(**data) @@ -98,7 +100,7 @@ class ClientCitation(AgentsModel, _SchemaMixin): position: int = 0 appearance: Optional[ClientCitationAppearance] = None - def __init__(self, **data): # removes linter errors for user-facing code + def __init__(self, **data): # removes linter errors for user-facing code super().__init__(**data) def __post_init__(self): @@ -118,9 +120,9 @@ class AIEntity(Entity, _SchemaMixin): citation: Optional[List[ClientCitation]] = None usage_info: Optional[SensitivityUsageInfo] = None - def __init__(self, **data): # removes linter errors for user-facing code + def __init__(self, **data): # removes linter errors for user-facing code super().__init__(**data) def __post_init__(self): if self.additional_type is None: - self.additional_type = ["AIGeneratedContent"] \ No newline at end of file + self.additional_type = ["AIGeneratedContent"] diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py index e90e0970..870b666c 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py @@ -16,11 +16,16 @@ class Entity(AgentsModel): :type type: str """ - model_config = ConfigDict(extra="allow", alias_generator=to_camel, validate_by_name=True, validate_by_alias=True) + model_config = ConfigDict( + extra="allow", + alias_generator=to_camel, + validate_by_name=True, + validate_by_alias=True, + ) type: str @property def additional_properties(self) -> dict[str, Any]: """Returns the set of properties that are not None.""" - return self.model_extra \ No newline at end of file + return self.model_extra diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/stream_info.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/stream_info.py index adc8c06e..d949ab64 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/stream_info.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/stream_info.py @@ -7,15 +7,16 @@ from .entity import Entity from .entity_types import EntityTypes + class StreamInfo(Entity): type: str = EntityTypes.STREAM_INFO.value stream_type: NonEmptyString = "streaming" stream_sequence: int - + stream_id: str = "" stream_result: str = "" feedback_loop_enabled: bool = False - feedback_loop: dict | None = None \ No newline at end of file + feedback_loop: dict | None = None diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py index 2f5f6c56..f5229d3d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py @@ -180,7 +180,7 @@ def set_citations(self, citations: List[Citation]) -> None: name=citation.title or f"Document #{curr_pos + 1}", abstract=CitationUtil.snippet(citation.content, 480), url=citation.url, - ) + ), ) curr_pos += 1 self._citations.append(client_citation) @@ -271,7 +271,7 @@ def create_activity() -> Activity | None: if self._ended: # Send final message activity = Activity( - type="message", + type="message", text=self._message or "end stream response", attachments=self._attachments or [], entities=[ @@ -369,7 +369,11 @@ async def _send_activity(self, activity: Activity) -> None: # the activity.add_ai_metadata call further down will add citations. # The extra condition here is to avoid duplication - if self._citations and not self._ended and not self._enable_generated_by_ai_label: + if ( + self._citations + and not self._ended + and not self._enable_generated_by_ai_label + ): # Filter out the citations unused in content. curr_citations = CitationUtil.get_used_citations( self._message, self._citations diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py index 5dc5e8ad..865e084c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py @@ -209,7 +209,7 @@ async def reply_to_activity( conversation_id, body.type, ) - + async with self.client.post( url, json=body.model_dump( diff --git a/tests/activity/entity/test_serialization.py b/tests/activity/entity/test_serialization.py index 5498bd9b..052c4983 100644 --- a/tests/activity/entity/test_serialization.py +++ b/tests/activity/entity/test_serialization.py @@ -8,13 +8,13 @@ SensitivityUsageInfo, ) + @pytest.mark.parametrize( - "entity_cls" - [ + "entity_cls"[ ClientCitation, SensitivityUsageInfo, ClientCitationAppearance, - SensitivityPattern + SensitivityPattern, ] ) def test_schema_mixin_at_type_serialization(entity_cls): @@ -29,6 +29,7 @@ def test_schema_mixin_at_type_serialization(entity_cls): assert "@type" in data assert data["@type"] == expected + def test_schema_mixin_at_context_serialization(): ai_entity = AIEntity() @@ -36,4 +37,4 @@ def test_schema_mixin_at_context_serialization(): data = ai_entity.model_dump(exclude_unset=True) assert data["@type"] == AIEntity.at_type - assert data["@context"] == AIEntity.at_context \ No newline at end of file + assert data["@context"] == AIEntity.at_context diff --git a/tests/copilotstudio_client/test_copilot_client.py b/tests/copilotstudio_client/test_copilot_client.py index e82e2869..cffc82f6 100644 --- a/tests/copilotstudio_client/test_copilot_client.py +++ b/tests/copilotstudio_client/test_copilot_client.py @@ -16,6 +16,7 @@ from aiohttp import ClientSession, ClientError from urllib.parse import urlparse + @pytest.mark.asyncio async def test_copilot_client_error(mocker): # Define the connection settings From e634a9172e551f14b8aaecea54b82c8556b0d1a3 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 18 Mar 2026 13:40:50 -0700 Subject: [PATCH 17/29] Removing unused imports --- .../microsoft_agents/activity/entity/entity.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py index 870b666c..ca3e272b 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py @@ -3,8 +3,7 @@ from typing import Any -from pydantic import model_serializer, model_validator -from pydantic.alias_generators import to_camel, to_snake +from pydantic.alias_generators import to_camel from ..agents_model import AgentsModel, ConfigDict From eedfd6c3688b434eaff69cb5c4edcf1ba8a292fe Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 18 Mar 2026 13:42:14 -0700 Subject: [PATCH 18/29] Small fixes --- .../microsoft_agents/activity/entity/_schema_mixin.py | 2 +- tests/activity/entity/test_serialization.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py index 805f105f..646477c6 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py @@ -15,6 +15,6 @@ def serialize_model( ) -> dict[str, object]: serialized = handler(self) serialized["@type"] = self.at_type - if hasattr(self, "@context"): + if hasattr(self, "at_context"): serialized["@context"] = getattr(self, "at_context") return serialized diff --git a/tests/activity/entity/test_serialization.py b/tests/activity/entity/test_serialization.py index 052c4983..3bcd5672 100644 --- a/tests/activity/entity/test_serialization.py +++ b/tests/activity/entity/test_serialization.py @@ -10,7 +10,8 @@ @pytest.mark.parametrize( - "entity_cls"[ + "entity_cls", + [ ClientCitation, SensitivityUsageInfo, ClientCitationAppearance, From 8d1b30026fb6dbfbd2db3030656ccbd04e946158 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 18 Mar 2026 15:19:58 -0700 Subject: [PATCH 19/29] Removing unnecessary dummy constructors --- .../microsoft_agents/activity/activity.py | 10 +--------- .../activity/entity/ai_entity.py | 17 ++--------------- .../microsoft_agents/activity/entity/entity.py | 10 ++++++++++ .../activity/entity/product_info.py | 2 +- tests/activity/entity/test_serialization.py | 9 +++------ tests/activity/test_activity.py | 1 + 6 files changed, 18 insertions(+), 31 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index a495bba6..b146a459 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -157,9 +157,7 @@ class Activity(AgentsModel, _ChannelIdFieldMixin): local_timestamp: datetime = None local_timezone: NonEmptyString = None service_url: NonEmptyString = None - from_property: ChannelAccount = Field( - None, validation_alias="from", serialization_alias="from" - ) + from_property: ChannelAccount = Field(None, alias="from") conversation: ConversationAccount = None recipient: ChannelAccount = None text_format: NonEmptyString = None @@ -195,12 +193,6 @@ class Activity(AgentsModel, _ChannelIdFieldMixin): semantic_action: SemanticAction = None caller_id: NonEmptyString = None - # fixes user-facing linting issues - def __init__( - self, *, from_property: ChannelAccount | None = None, **data: Any - ) -> None: - super().__init__(**data) - @model_validator(mode="wrap") @classmethod def _validate_channel_id( diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py index c31daa70..5048ae82 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py @@ -8,7 +8,6 @@ from ._schema_mixin import _SchemaMixin from .entity import Entity - class ClientCitationIconName(str, Enum): """Enumeration of supported citation icon names.""" @@ -66,9 +65,6 @@ class SensitivityUsageInfo(AgentsModel, _SchemaMixin): position: Optional[int] = None pattern: Optional[SensitivityPattern] = None - def __init__(self, **data): # removes linter errors for user-facing code - super().__init__(**data) - class ClientCitationAppearance(AgentsModel, _SchemaMixin): """Appearance information for a client citation.""" @@ -84,10 +80,6 @@ class ClientCitationAppearance(AgentsModel, _SchemaMixin): keywords: Optional[List[str]] = None usage_info: Optional[SensitivityUsageInfo] = None - def __init__(self, **data): # removes linter errors for user-facing code - super().__init__(**data) - - class ClientCitation(AgentsModel, _SchemaMixin): """ Represents a Teams client citation to be included in a message. @@ -100,15 +92,12 @@ class ClientCitation(AgentsModel, _SchemaMixin): position: int = 0 appearance: Optional[ClientCitationAppearance] = None - def __init__(self, **data): # removes linter errors for user-facing code - super().__init__(**data) - def __post_init__(self): if self.appearance is None: self.appearance = ClientCitationAppearance() -class AIEntity(Entity, _SchemaMixin): +class AIEntity(AgentsModel, _SchemaMixin): """Entity indicating AI-generated content.""" at_type: Literal["Message"] = "Message" @@ -116,13 +105,11 @@ class AIEntity(Entity, _SchemaMixin): type: str = "https://schema.org/Message" id: str = "" + additional_type: Optional[List[str]] = None citation: Optional[List[ClientCitation]] = None usage_info: Optional[SensitivityUsageInfo] = None - def __init__(self, **data): # removes linter errors for user-facing code - super().__init__(**data) - def __post_init__(self): if self.additional_type is None: self.additional_type = ["AIGeneratedContent"] diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py index ca3e272b..9acde60a 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py @@ -4,6 +4,7 @@ from typing import Any from pydantic.alias_generators import to_camel +from pydantic import model_serializer, SerializerFunctionWrapHandler from ..agents_model import AgentsModel, ConfigDict @@ -28,3 +29,12 @@ class Entity(AgentsModel): def additional_properties(self) -> dict[str, Any]: """Returns the set of properties that are not None.""" return self.model_extra + + # ensures type is included when serializing, even when exclude_unset=True + @model_serializer(mode="wrap") + def serialize_with_type( + self, handler: SerializerFunctionWrapHandler + ) -> dict[str, object]: + serialized = handler(self) + serialized["type"] = self.type + return serialized diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/product_info.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/product_info.py index 17bbc091..d678281b 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/product_info.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/product_info.py @@ -16,5 +16,5 @@ class ProductInfo(Entity): :type id: str """ - type: Literal[EntityTypes.PRODUCT_INFO] = EntityTypes.PRODUCT_INFO + type: Literal[EntityTypes.PRODUCT_INFO.value] = EntityTypes.PRODUCT_INFO.value id: str = None diff --git a/tests/activity/entity/test_serialization.py b/tests/activity/entity/test_serialization.py index 3bcd5672..77446695 100644 --- a/tests/activity/entity/test_serialization.py +++ b/tests/activity/entity/test_serialization.py @@ -20,15 +20,12 @@ ) def test_schema_mixin_at_type_serialization(entity_cls): - expected = entity_cls.at_type - assert isinstance(expected, str) and expected != "" - entity = entity_cls() data = entity.model_dump(exclude_unset=True) assert "@type" in data - assert data["@type"] == expected + assert data["@type"] == entity.at_type def test_schema_mixin_at_context_serialization(): @@ -37,5 +34,5 @@ def test_schema_mixin_at_context_serialization(): data = ai_entity.model_dump(exclude_unset=True) - assert data["@type"] == AIEntity.at_type - assert data["@context"] == AIEntity.at_context + assert data["@type"] == ai_entity.at_type + assert data["@context"] == ai_entity.at_context diff --git a/tests/activity/test_activity.py b/tests/activity/test_activity.py index 7794a387..35a04893 100644 --- a/tests/activity/test_activity.py +++ b/tests/activity/test_activity.py @@ -55,6 +55,7 @@ def activity(self): return create_test_activity("en-us") def test_get_conversation_reference(self, activity): + conversation_reference = activity.get_conversation_reference() assert activity.id == conversation_reference.activity_id From f8639c3570a566143458ce86bebe590a878b5e82 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 19 Mar 2026 10:31:09 -0700 Subject: [PATCH 20/29] Fixing tests --- .../microsoft_agents/activity/entity/ai_entity.py | 2 ++ tests/activity/entity/test_serialization.py | 2 +- tests/hosting_core/app/streaming/test_streaming_response.py | 6 +++--- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py index 5048ae82..20f96fb0 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py @@ -8,6 +8,7 @@ from ._schema_mixin import _SchemaMixin from .entity import Entity + class ClientCitationIconName(str, Enum): """Enumeration of supported citation icon names.""" @@ -80,6 +81,7 @@ class ClientCitationAppearance(AgentsModel, _SchemaMixin): keywords: Optional[List[str]] = None usage_info: Optional[SensitivityUsageInfo] = None + class ClientCitation(AgentsModel, _SchemaMixin): """ Represents a Teams client citation to be included in a message. diff --git a/tests/activity/entity/test_serialization.py b/tests/activity/entity/test_serialization.py index 77446695..d913ee63 100644 --- a/tests/activity/entity/test_serialization.py +++ b/tests/activity/entity/test_serialization.py @@ -16,7 +16,7 @@ SensitivityUsageInfo, ClientCitationAppearance, SensitivityPattern, - ] + ], ) def test_schema_mixin_at_type_serialization(entity_cls): diff --git a/tests/hosting_core/app/streaming/test_streaming_response.py b/tests/hosting_core/app/streaming/test_streaming_response.py index 7c3630a5..3080a4f3 100644 --- a/tests/hosting_core/app/streaming/test_streaming_response.py +++ b/tests/hosting_core/app/streaming/test_streaming_response.py @@ -176,7 +176,7 @@ async def test_set_citations_adds_only_used_citations_when_streaming_activity_is citation_entities = [ entity for entity in streaming_activity.entities - if getattr(entity, "schema_type", None) == "Message" + if getattr(entity, "at_type", None) == "Message" ] assert len(citation_entities) == 1 assert len(citation_entities[0].citation) == 1 @@ -517,5 +517,5 @@ async def test_feedback_loop_type_without_enable_does_not_emit_feedback_loop_obj e for e in sent.entities if getattr(e, "type", None) == "streaminfo" ) - assert not hasattr(streaminfo, "feedback_loop") - assert getattr(streaminfo, "feedback_loop_enabled", None) is False + assert not streaminfo.feedback_loop + assert not streaminfo.feedback_loop_enabled From 478651d7fde2f3c9d6edb0120920504de55fe88b Mon Sep 17 00:00:00 2001 From: rodrigobr-msft Date: Thu, 19 Mar 2026 10:44:53 -0700 Subject: [PATCH 21/29] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../microsoft_agents/activity/entity/ai_entity.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py index 20f96fb0..769903e1 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py @@ -4,6 +4,7 @@ from enum import Enum from typing import List, Optional, Literal +from pydantic import Field from ..agents_model import AgentsModel from ._schema_mixin import _SchemaMixin from .entity import Entity @@ -108,10 +109,6 @@ class AIEntity(AgentsModel, _SchemaMixin): type: str = "https://schema.org/Message" id: str = "" - additional_type: Optional[List[str]] = None + additional_type: List[str] = Field(default_factory=lambda: ["AIGeneratedContent"]) citation: Optional[List[ClientCitation]] = None usage_info: Optional[SensitivityUsageInfo] = None - - def __post_init__(self): - if self.additional_type is None: - self.additional_type = ["AIGeneratedContent"] From c9b25e92ece633f81964827da3eca988f413ca9b Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 19 Mar 2026 10:53:04 -0700 Subject: [PATCH 22/29] Adding formatting and comment --- .../microsoft_agents/activity/entity/ai_entity.py | 4 ++-- .../microsoft_agents/activity/entity/entity.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py index 769903e1..0e7adfb5 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py @@ -99,8 +99,8 @@ def __post_init__(self): if self.appearance is None: self.appearance = ClientCitationAppearance() - -class AIEntity(AgentsModel, _SchemaMixin): +# in the future, we need a better way to resolve the different serializers. +class AIEntity(_SchemaMixin, Entity): """Entity indicating AI-generated content.""" at_type: Literal["Message"] = "Message" diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py index 9acde60a..e11b1842 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py @@ -7,6 +7,7 @@ from pydantic import model_serializer, SerializerFunctionWrapHandler from ..agents_model import AgentsModel, ConfigDict +from ._schema_mixin import _SchemaMixin class Entity(AgentsModel): From d77f4795d9118e404f09dbe79530525a8dbe4fbb Mon Sep 17 00:00:00 2001 From: rodrigobr-msft Date: Thu, 19 Mar 2026 10:54:41 -0700 Subject: [PATCH 23/29] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../microsoft_agents/activity/entity/stream_info.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/stream_info.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/stream_info.py index d949ab64..a7f8b6e9 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/stream_info.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/stream_info.py @@ -1,8 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import Literal - from .._type_aliases import NonEmptyString from .entity import Entity from .entity_types import EntityTypes From ea765fd279ced885965f0a6ce97c05bd6d4915ec Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 19 Mar 2026 10:57:49 -0700 Subject: [PATCH 24/29] Reformatting --- .../microsoft_agents/activity/entity/ai_entity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py index 0e7adfb5..973610a8 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py @@ -99,6 +99,7 @@ def __post_init__(self): if self.appearance is None: self.appearance = ClientCitationAppearance() + # in the future, we need a better way to resolve the different serializers. class AIEntity(_SchemaMixin, Entity): """Entity indicating AI-generated content.""" From a8a4a8320ba6ad05c1223da387d3fc0eec40a286 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 19 Mar 2026 12:02:50 -0700 Subject: [PATCH 25/29] Redid serialization/validation logic to allow for extra property aliasing --- .../activity/entity/_schema_mixin.py | 47 +++++++++++++++---- .../activity/entity/ai_entity.py | 2 +- .../activity/entity/entity.py | 43 +++++++++++++---- tests/activity/pydantic/test_activity_io.py | 4 -- 4 files changed, 72 insertions(+), 24 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py index 646477c6..44721a34 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py @@ -1,20 +1,49 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import Any -from pydantic import model_serializer, SerializerFunctionWrapHandler +from typing import Any, Self +from pydantic import ( + model_serializer, + model_validator, + ModelWrapValidatorHandler, + SerializerFunctionWrapHandler, + BaseModel, +) -class _SchemaMixin: +def validate_schema_model(cls, data: Any, handler: ModelWrapValidatorHandler): + model = handler(data) + if isinstance(data, dict): + if "@type" in data: + setattr(model, "at_type", data["@type"]) + if "@context" in data: + setattr(model, "at_context", data["@context"]) + return model - at_type: Any + +def serialize_schema_model( + self, handler: SerializerFunctionWrapHandler +) -> dict[str, object]: + serialized = handler(self) + if hasattr(self, "at_type"): + serialized["@type"] = getattr(self, "at_type") + if hasattr(self, "at_context"): + serialized["@context"] = getattr(self, "at_context") + return serialized + + +class _SchemaMixin(BaseModel): + """Mixin class to force inclusion of @property fields when serializing.""" + + @model_validator(mode="wrap") + @classmethod + def validate_model( + cls, data: Any, handler: ModelWrapValidatorHandler[Self] + ) -> Self: + return validate_schema_model(cls, data, handler) @model_serializer(mode="wrap") def serialize_model( self, handler: SerializerFunctionWrapHandler ) -> dict[str, object]: - serialized = handler(self) - serialized["@type"] = self.at_type - if hasattr(self, "at_context"): - serialized["@context"] = getattr(self, "at_context") - return serialized + return serialize_schema_model(self, handler) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py index 973610a8..6da512cc 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py @@ -101,7 +101,7 @@ def __post_init__(self): # in the future, we need a better way to resolve the different serializers. -class AIEntity(_SchemaMixin, Entity): +class AIEntity(Entity): """Entity indicating AI-generated content.""" at_type: Literal["Message"] = "Message" diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py index e11b1842..29674ca1 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py @@ -1,13 +1,19 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import Any +from typing import Any, Self -from pydantic.alias_generators import to_camel -from pydantic import model_serializer, SerializerFunctionWrapHandler +from pydantic import ( + model_serializer, + model_validator, + SerializationInfo, + ModelWrapValidatorHandler, + SerializerFunctionWrapHandler, +) +from pydantic.alias_generators import to_camel, to_snake from ..agents_model import AgentsModel, ConfigDict -from ._schema_mixin import _SchemaMixin +from ._schema_mixin import validate_schema_model, serialize_schema_model class Entity(AgentsModel): @@ -31,11 +37,28 @@ def additional_properties(self) -> dict[str, Any]: """Returns the set of properties that are not None.""" return self.model_extra - # ensures type is included when serializing, even when exclude_unset=True + @model_validator(mode="wrap") + @classmethod + def to_snake_for_all( + cls, data: Any, handler: ModelWrapValidatorHandler[Self] + ) -> Self: + if isinstance(data, dict): + new_data = {to_snake(k): v for k, v in data.items()} + return validate_schema_model(cls, new_data, handler) + return validate_schema_model(cls, data, handler) + @model_serializer(mode="wrap") - def serialize_with_type( - self, handler: SerializerFunctionWrapHandler + def to_camel_for_all( + self, handler: SerializerFunctionWrapHandler, info: SerializationInfo ) -> dict[str, object]: - serialized = handler(self) - serialized["type"] = self.type - return serialized + data = serialize_schema_model(self, handler) + new_data: dict + + if info.by_alias: + new_data = {to_camel(k): v for k, v in data.items()} + else: + new_data = {k: v for k, v in data.items()} + + new_data["type"] = self.type # ensure type is always included + + return new_data diff --git a/tests/activity/pydantic/test_activity_io.py b/tests/activity/pydantic/test_activity_io.py index 2ca8734b..f13f0ee1 100644 --- a/tests/activity/pydantic/test_activity_io.py +++ b/tests/activity/pydantic/test_activity_io.py @@ -1,15 +1,11 @@ import pytest -from pydantic import ValidationError - from microsoft_agents.activity import ( Activity, ChannelId, Entity, EntityTypes, ProductInfo, - ConversationReference, - ConversationAccount, ) From 114b0d9f16d8b3d547c256d8f4d89e296e7c03b0 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 19 Mar 2026 12:47:58 -0700 Subject: [PATCH 26/29] Another commit --- .../microsoft_agents/activity/entity/_schema_mixin.py | 4 ++++ .../microsoft_agents/activity/entity/ai_entity.py | 7 +------ .../microsoft_agents/activity/entity/entity.py | 2 +- tests/activity/entity/test_serialization.py | 3 +-- .../hosting_core/app/streaming/test_streaming_response.py | 2 +- 5 files changed, 8 insertions(+), 10 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py index 44721a34..a4c4a514 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py @@ -18,6 +18,8 @@ def validate_schema_model(cls, data: Any, handler: ModelWrapValidatorHandler): setattr(model, "at_type", data["@type"]) if "@context" in data: setattr(model, "at_context", data["@context"]) + if "@id" in data: + setattr(model, "at_id", data["@id"]) return model @@ -29,6 +31,8 @@ def serialize_schema_model( serialized["@type"] = getattr(self, "at_type") if hasattr(self, "at_context"): serialized["@context"] = getattr(self, "at_context") + if hasattr(self, "at_id"): + serialized["@id"] = getattr(self, "at_id") return serialized diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py index 6da512cc..3f582dda 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py @@ -93,14 +93,9 @@ class ClientCitation(AgentsModel, _SchemaMixin): at_type: Literal["Claim"] = "Claim" position: int = 0 - appearance: Optional[ClientCitationAppearance] = None + appearance: ClientCitationAppearance = Field(default_factory=ClientCitationAppearance) - def __post_init__(self): - if self.appearance is None: - self.appearance = ClientCitationAppearance() - -# in the future, we need a better way to resolve the different serializers. class AIEntity(Entity): """Entity indicating AI-generated content.""" diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py index 29674ca1..d2cec608 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py @@ -59,6 +59,6 @@ def to_camel_for_all( else: new_data = {k: v for k, v in data.items()} - new_data["type"] = self.type # ensure type is always included + # new_data["type"] = self.type # ensure type is always included return new_data diff --git a/tests/activity/entity/test_serialization.py b/tests/activity/entity/test_serialization.py index d913ee63..393c5c94 100644 --- a/tests/activity/entity/test_serialization.py +++ b/tests/activity/entity/test_serialization.py @@ -34,5 +34,4 @@ def test_schema_mixin_at_context_serialization(): data = ai_entity.model_dump(exclude_unset=True) - assert data["@type"] == ai_entity.at_type - assert data["@context"] == ai_entity.at_context + assert data["@context"] == "https://schema.org" diff --git a/tests/hosting_core/app/streaming/test_streaming_response.py b/tests/hosting_core/app/streaming/test_streaming_response.py index 3080a4f3..21e3d5b5 100644 --- a/tests/hosting_core/app/streaming/test_streaming_response.py +++ b/tests/hosting_core/app/streaming/test_streaming_response.py @@ -176,7 +176,7 @@ async def test_set_citations_adds_only_used_citations_when_streaming_activity_is citation_entities = [ entity for entity in streaming_activity.entities - if getattr(entity, "at_type", None) == "Message" + if getattr(entity, "type", None) == "https://schema.org/Message" ] assert len(citation_entities) == 1 assert len(citation_entities[0].citation) == 1 From 4a6f7708eca367a5e7d6cf4b44ceb5f399d82e46 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 19 Mar 2026 14:27:17 -0700 Subject: [PATCH 27/29] Addressed fixes to reincorporate snake/camel case aliasing for extra properties in Entity --- .../activity/entity/_schema_mixin.py | 34 +++++++++++-------- .../activity/entity/ai_entity.py | 4 ++- .../activity/entity/entity.py | 28 +++++++++++---- tests/activity/entity/test_serialization.py | 27 +++++++++++++-- 4 files changed, 68 insertions(+), 25 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py index a4c4a514..9f2d4ad6 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py @@ -7,11 +7,13 @@ model_validator, ModelWrapValidatorHandler, SerializerFunctionWrapHandler, + SerializationInfo, BaseModel, ) -def validate_schema_model(cls, data: Any, handler: ModelWrapValidatorHandler): +def validate_schema_model(data: Any, handler: ModelWrapValidatorHandler): + """Custom validator to handle the aliases @type, @context, and @id if defined in the destination type.""" model = handler(data) if isinstance(data, dict): if "@type" in data: @@ -24,15 +26,17 @@ def validate_schema_model(cls, data: Any, handler: ModelWrapValidatorHandler): def serialize_schema_model( - self, handler: SerializerFunctionWrapHandler -) -> dict[str, object]: + self, handler: SerializerFunctionWrapHandler, info: SerializationInfo +) -> dict[str, Any]: + """Custom serializer to convert keys to force inclusion of @type, @context, and @id if defined.""" serialized = handler(self) - if hasattr(self, "at_type"): - serialized["@type"] = getattr(self, "at_type") - if hasattr(self, "at_context"): - serialized["@context"] = getattr(self, "at_context") - if hasattr(self, "at_id"): - serialized["@id"] = getattr(self, "at_id") + if info.by_alias: + if hasattr(self, "at_type"): + serialized["@type"] = getattr(self, "at_type") + if hasattr(self, "at_context"): + serialized["@context"] = getattr(self, "at_context") + if hasattr(self, "at_id"): + serialized["@id"] = getattr(self, "at_id") return serialized @@ -41,13 +45,13 @@ class _SchemaMixin(BaseModel): @model_validator(mode="wrap") @classmethod - def validate_model( + def _validate_model( cls, data: Any, handler: ModelWrapValidatorHandler[Self] ) -> Self: - return validate_schema_model(cls, data, handler) + return validate_schema_model(data, handler) @model_serializer(mode="wrap") - def serialize_model( - self, handler: SerializerFunctionWrapHandler - ) -> dict[str, object]: - return serialize_schema_model(self, handler) + def _serialize_model( + self, handler: SerializerFunctionWrapHandler, info: SerializationInfo + ) -> dict[str, Any]: + return serialize_schema_model(self, handler, info) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py index 3f582dda..68f1a6bf 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py @@ -93,7 +93,9 @@ class ClientCitation(AgentsModel, _SchemaMixin): at_type: Literal["Claim"] = "Claim" position: int = 0 - appearance: ClientCitationAppearance = Field(default_factory=ClientCitationAppearance) + appearance: ClientCitationAppearance = Field( + default_factory=ClientCitationAppearance + ) class AIEntity(Entity): diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py index d2cec608..15db4190 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py @@ -16,6 +16,13 @@ from ._schema_mixin import validate_schema_model, serialize_schema_model +def _to_camel_exclude_at(k: str) -> str: + """Helper function to convert keys to camelCase while preserving keys that start with '@'.""" + if k.startswith("@"): + return k # preserve keys starting with '@' + return to_camel(k) + + class Entity(AgentsModel): """Metadata object pertaining to an activity. @@ -39,26 +46,33 @@ def additional_properties(self) -> dict[str, Any]: @model_validator(mode="wrap") @classmethod - def to_snake_for_all( + def _validate_model( cls, data: Any, handler: ModelWrapValidatorHandler[Self] ) -> Self: + """Custom validator to handle both camelCase and snake_case keys, as well as @type, @context, and @id.""" + if isinstance(data, dict): new_data = {to_snake(k): v for k, v in data.items()} - return validate_schema_model(cls, new_data, handler) - return validate_schema_model(cls, data, handler) + return validate_schema_model(new_data, handler) + return validate_schema_model(data, handler) @model_serializer(mode="wrap") - def to_camel_for_all( + def _serialize_model( self, handler: SerializerFunctionWrapHandler, info: SerializationInfo ) -> dict[str, object]: - data = serialize_schema_model(self, handler) + """Custom serializer to convert keys to camelCase and include @type, @context, and @id as needed. + + Forces the inclusion of the 'type' field in the serialized output, as it is a required field for Entity. + """ + + data = serialize_schema_model(self, handler, info) new_data: dict if info.by_alias: - new_data = {to_camel(k): v for k, v in data.items()} + new_data = {_to_camel_exclude_at(k): v for k, v in data.items()} else: new_data = {k: v for k, v in data.items()} - # new_data["type"] = self.type # ensure type is always included + new_data["type"] = self.type # ensure type is always included return new_data diff --git a/tests/activity/entity/test_serialization.py b/tests/activity/entity/test_serialization.py index 393c5c94..3f702f79 100644 --- a/tests/activity/entity/test_serialization.py +++ b/tests/activity/entity/test_serialization.py @@ -22,16 +22,39 @@ def test_schema_mixin_at_type_serialization(entity_cls): entity = entity_cls() - data = entity.model_dump(exclude_unset=True) + data = entity.model_dump(exclude_unset=True, by_alias=True) assert "@type" in data assert data["@type"] == entity.at_type + assert "at_type" not in data + + +@pytest.mark.parametrize( + "entity_cls", + [ + ClientCitation, + SensitivityUsageInfo, + ClientCitationAppearance, + SensitivityPattern, + ], +) +def test_schema_mixin_at_type_serialization_no_alias(entity_cls): + + entity = entity_cls() + + data = entity.model_dump(exclude_unset=True, by_alias=False) + + assert "@type" not in data def test_schema_mixin_at_context_serialization(): ai_entity = AIEntity() - data = ai_entity.model_dump(exclude_unset=True) + data = ai_entity.model_dump(exclude_unset=True, by_alias=True) + assert data["@type"] == "Message" assert data["@context"] == "https://schema.org" + + assert "at_type" not in data + assert "at_context" not in data From 08d7b127043ae5f72191ccaa22bf02940e5f191a Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 19 Mar 2026 14:32:55 -0700 Subject: [PATCH 28/29] Removed Self annotation unsupported in 3.10 --- .../microsoft_agents/activity/entity/_schema_mixin.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py index 9f2d4ad6..d96dc2fc 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_schema_mixin.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import Any, Self +from typing import Any from pydantic import ( model_serializer, model_validator, @@ -45,9 +45,7 @@ class _SchemaMixin(BaseModel): @model_validator(mode="wrap") @classmethod - def _validate_model( - cls, data: Any, handler: ModelWrapValidatorHandler[Self] - ) -> Self: + def _validate_model(cls, data: Any, handler: ModelWrapValidatorHandler): return validate_schema_model(data, handler) @model_serializer(mode="wrap") From 69e84ec8885462f7e8734894b94d881a31d6e956 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 19 Mar 2026 14:35:08 -0700 Subject: [PATCH 29/29] Removed Self in entity.py --- .../microsoft_agents/activity/entity/entity.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py index 15db4190..dff08db1 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import Any, Self +from typing import Any from pydantic import ( model_serializer, @@ -46,9 +46,7 @@ def additional_properties(self) -> dict[str, Any]: @model_validator(mode="wrap") @classmethod - def _validate_model( - cls, data: Any, handler: ModelWrapValidatorHandler[Self] - ) -> Self: + def _validate_model(cls, data: Any, handler: ModelWrapValidatorHandler): """Custom validator to handle both camelCase and snake_case keys, as well as @type, @context, and @id.""" if isinstance(data, dict):