From ba093431c5d65823dc94b63ce014f77551eddf18 Mon Sep 17 00:00:00 2001 From: Alex Bitar Date: Thu, 10 Sep 2026 20:23:44 +0400 Subject: [PATCH 1/7] fix: return stream ID when closing streamed activity --- .../src/microsoft_teams/apps/http_stream.py | 7 +++ packages/apps/tests/test_http_stream.py | 47 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/packages/apps/src/microsoft_teams/apps/http_stream.py b/packages/apps/src/microsoft_teams/apps/http_stream.py index cf52b321..49514cda 100644 --- a/packages/apps/src/microsoft_teams/apps/http_stream.py +++ b/packages/apps/src/microsoft_teams/apps/http_stream.py @@ -248,6 +248,13 @@ async def close(self) -> Optional[SentActivity]: # streamInfo entity + stream channel data so this routes to update, not create. res = await self._send_final() + # Teams only returns the stream id on the first streaming request. Subsequent + # responses, including the final streamed message, are empty and the API client + # represents those with a placeholder id. The stream id captured from the first + # chunk is the stable id for the finalized activity. + assert self._id is not None + res = res.model_copy(update={"id": self._id}) + # Emit close event self._events.emit("close", res) diff --git a/packages/apps/tests/test_http_stream.py b/packages/apps/tests/test_http_stream.py index 892f6ad4..cee1cb11 100644 --- a/packages/apps/tests/test_http_stream.py +++ b/packages/apps/tests/test_http_stream.py @@ -504,6 +504,45 @@ async def test_close_returns_none_when_canceled(self, mock_api_client, conversat result = await stream.close() assert result is None + @pytest.mark.asyncio + async def test_close_returns_stream_id_when_final_response_has_placeholder( + self, mock_api_client, conversation_reference, patch_loop_call_later + ): + """The final streaming response is empty, so close() must retain the first chunk's id.""" + loop = asyncio.get_running_loop() + patcher, scheduled = patch_loop_call_later(loop) + close_activities: list[SentActivity] = [] + close_event = asyncio.Event() + + async def handle_close(activity: SentActivity) -> None: + close_activities.append(activity) + close_event.set() + + async def mock_create(conversation_id, activity): + if any( + entity.type == "streaminfo" and entity.stream_type == "final" for entity in (activity.entities or []) + ): + return SentActivity(id="DO_NOT_USE_PLACEHOLDER_ID", activity_params=activity) + return SentActivity(id="stream-1", activity_params=activity) + + mock_api_client.conversations.create_activity = mock_create + + with patcher: + stream = HttpStream(mock_api_client, conversation_reference) + stream.on_close(handle_close) + + stream.emit("Streamed content") + await asyncio.sleep(0) + await self._run_scheduled_flushes(scheduled) + + result = await stream.close() + + assert result is not None + assert result.id == "stream-1" + await asyncio.wait_for(close_event.wait(), timeout=1) + assert close_activities == [result] + assert close_activities[0].id == "stream-1" + @pytest.mark.asyncio async def test_final_activity_last_wins(self, mock_api_client, conversation_reference, patch_loop_call_later): """When multiple MessageActivityInputs are emitted, the last one's non-text fields are used.""" @@ -759,6 +798,13 @@ async def mock_update(conversation_id, activity_id, activity): @pytest.mark.asyncio async def test_close_waits_for_flush_to_complete(self, mock_api_client, conversation_reference): """close() must not send the final message while a flush is still mid-await.""" + + async def mock_send(conversation_id, activity): + mock_api_client.send_call_count += 1 + mock_api_client.sent_activities.append(activity) + return SentActivity(id="DO_NOT_USE_PLACEHOLDER_ID", activity_params=activity) + + mock_api_client.conversations.create_activity = mock_send stream = HttpStream(mock_api_client, conversation_reference) # Simulate a flush in progress: lock held, _id assigned, text accumulated. @@ -780,6 +826,7 @@ async def test_close_waits_for_flush_to_complete(self, mock_api_client, conversa result = await close_task assert result is not None + assert result.id == "activity-1" assert mock_api_client.send_call_count == 1 assert mock_api_client.sent_activities[0].text == "Response text" From 01307ec9e11dab6aff7a8d1326bc7920c821fc09 Mon Sep 17 00:00:00 2001 From: Alex Bitar <4061317+AlxBit@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:06:34 +0400 Subject: [PATCH 2/7] Fix assertion to handle None id gracefully Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/apps/src/microsoft_teams/apps/http_stream.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/apps/src/microsoft_teams/apps/http_stream.py b/packages/apps/src/microsoft_teams/apps/http_stream.py index 49514cda..a4a0aeba 100644 --- a/packages/apps/src/microsoft_teams/apps/http_stream.py +++ b/packages/apps/src/microsoft_teams/apps/http_stream.py @@ -252,8 +252,8 @@ async def close(self) -> Optional[SentActivity]: # responses, including the final streamed message, are empty and the API client # represents those with a placeholder id. The stream id captured from the first # chunk is the stable id for the finalized activity. - assert self._id is not None - res = res.model_copy(update={"id": self._id}) + if self._id is not None: + res = res.model_copy(update={"id": self._id}) # Emit close event self._events.emit("close", res) From 4c95379526de2b53c3be87258dd4fa12eee63634 Mon Sep 17 00:00:00 2001 From: Alex Bitar <4061317+AlxBit@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:42:01 +0400 Subject: [PATCH 3/7] Add condition to check for placeholder ID in response Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/apps/src/microsoft_teams/apps/http_stream.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/apps/src/microsoft_teams/apps/http_stream.py b/packages/apps/src/microsoft_teams/apps/http_stream.py index a4a0aeba..55029230 100644 --- a/packages/apps/src/microsoft_teams/apps/http_stream.py +++ b/packages/apps/src/microsoft_teams/apps/http_stream.py @@ -252,7 +252,7 @@ async def close(self) -> Optional[SentActivity]: # responses, including the final streamed message, are empty and the API client # represents those with a placeholder id. The stream id captured from the first # chunk is the stable id for the finalized activity. - if self._id is not None: + if self._id is not None and res.id == "DO_NOT_USE_PLACEHOLDER_ID": res = res.model_copy(update={"id": self._id}) # Emit close event From e4ed756a6f5a187aabec4e217ff4c52de8cf1b3c Mon Sep 17 00:00:00 2001 From: Alex Bitar Date: Fri, 18 Sep 2026 19:14:42 +0400 Subject: [PATCH 4/7] fix: set activity id in _send() instead of close() --- packages/apps/src/microsoft_teams/apps/http_stream.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/apps/src/microsoft_teams/apps/http_stream.py b/packages/apps/src/microsoft_teams/apps/http_stream.py index 55029230..db3c1695 100644 --- a/packages/apps/src/microsoft_teams/apps/http_stream.py +++ b/packages/apps/src/microsoft_teams/apps/http_stream.py @@ -406,6 +406,9 @@ async def _send(self, to_send: Union[TypingActivityInput, MessageActivityInput]) else: res = await self._client.conversations.create_activity(self._ref.conversation.id, to_send) + if to_send.id: + res = res.model_copy(update={"id": to_send.id}) + return SentActivity.merge(to_send, res) except HTTPStatusError as e: # Various error codes are used for streaming. From 311114933d1df3b44a8a2ce220410d351ebb2aad Mon Sep 17 00:00:00 2001 From: Alex Bitar Date: Fri, 18 Sep 2026 19:09:17 +0400 Subject: [PATCH 5/7] test_http_stream: update mock_send() to match api client behavior --- packages/apps/tests/test_http_stream.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/apps/tests/test_http_stream.py b/packages/apps/tests/test_http_stream.py index cee1cb11..91d70f1c 100644 --- a/packages/apps/tests/test_http_stream.py +++ b/packages/apps/tests/test_http_stream.py @@ -50,6 +50,14 @@ def mock_api_client(self): async def mock_send(conversation_id, activity): client.send_call_count += 1 client.sent_activities.append(activity) + + stream_entities = [ + entity for entity in activity.entities or [] if entity.type == "streaminfo" and entity.stream_id + ] + stream_id = next((entity.stream_id for entity in stream_entities), None) + if stream_id: + return SentActivity(id="DO_NOT_USE_PLACEHOLDER_ID", activity_params=activity) + return SentActivity(id=f"activity-{client.send_call_count}", activity_params=activity) client.conversations.create_activity = mock_send @@ -799,12 +807,6 @@ async def mock_update(conversation_id, activity_id, activity): async def test_close_waits_for_flush_to_complete(self, mock_api_client, conversation_reference): """close() must not send the final message while a flush is still mid-await.""" - async def mock_send(conversation_id, activity): - mock_api_client.send_call_count += 1 - mock_api_client.sent_activities.append(activity) - return SentActivity(id="DO_NOT_USE_PLACEHOLDER_ID", activity_params=activity) - - mock_api_client.conversations.create_activity = mock_send stream = HttpStream(mock_api_client, conversation_reference) # Simulate a flush in progress: lock held, _id assigned, text accumulated. From 8e3915cc1d2ed1f764e4ac6ea1f9194af6895814 Mon Sep 17 00:00:00 2001 From: Alex Bitar Date: Fri, 18 Sep 2026 19:17:22 +0400 Subject: [PATCH 6/7] http_stream: revert setting id in close() --- packages/apps/src/microsoft_teams/apps/http_stream.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/apps/src/microsoft_teams/apps/http_stream.py b/packages/apps/src/microsoft_teams/apps/http_stream.py index db3c1695..f33588d3 100644 --- a/packages/apps/src/microsoft_teams/apps/http_stream.py +++ b/packages/apps/src/microsoft_teams/apps/http_stream.py @@ -248,13 +248,6 @@ async def close(self) -> Optional[SentActivity]: # streamInfo entity + stream channel data so this routes to update, not create. res = await self._send_final() - # Teams only returns the stream id on the first streaming request. Subsequent - # responses, including the final streamed message, are empty and the API client - # represents those with a placeholder id. The stream id captured from the first - # chunk is the stable id for the finalized activity. - if self._id is not None and res.id == "DO_NOT_USE_PLACEHOLDER_ID": - res = res.model_copy(update={"id": self._id}) - # Emit close event self._events.emit("close", res) From bec2e0e4a645059c0358d83e932a8c41c0cb529e Mon Sep 17 00:00:00 2001 From: Alex Bitar Date: Fri, 18 Sep 2026 19:22:42 +0400 Subject: [PATCH 7/7] test_http_stream: revert line break --- packages/apps/tests/test_http_stream.py | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/apps/tests/test_http_stream.py b/packages/apps/tests/test_http_stream.py index 91d70f1c..dd8a93d3 100644 --- a/packages/apps/tests/test_http_stream.py +++ b/packages/apps/tests/test_http_stream.py @@ -806,7 +806,6 @@ async def mock_update(conversation_id, activity_id, activity): @pytest.mark.asyncio async def test_close_waits_for_flush_to_complete(self, mock_api_client, conversation_reference): """close() must not send the final message while a flush is still mid-await.""" - stream = HttpStream(mock_api_client, conversation_reference) # Simulate a flush in progress: lock held, _id assigned, text accumulated.