From 5eef4df3138794c9e254394e1cfb3e6ebd5aa455 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 29 Oct 2025 14:32:46 -0700 Subject: [PATCH 01/16] TypingIndicator simplification --- .../hosting/core/app/typing_indicator.py | 77 +++++++------------ 1 file changed, 27 insertions(+), 50 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py index f60d8b45..592754b4 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py @@ -4,9 +4,9 @@ """ from __future__ import annotations -import asyncio import logging +from threading import Timer from typing import Optional from microsoft_agents.hosting.core import TurnContext @@ -20,59 +20,36 @@ class TypingIndicator: Encapsulates the logic for sending "typing" activity to the user. """ - def __init__(self, intervalSeconds=1) -> None: - self._intervalSeconds = intervalSeconds - self._task: Optional[asyncio.Task] = None - self._running: bool = False - self._lock = asyncio.Lock() + _interval: int + _timer: Optional[Timer] = None - async def start(self, context: TurnContext) -> None: - async with self._lock: - if self._running: - return + def __init__(self, interval=10) -> None: + self._interval = interval - logger.debug( - f"Starting typing indicator with interval: {self._intervalSeconds} seconds" - ) - self._running = True - self._task = asyncio.create_task(self._typing_loop(context)) + async def start(self, context: TurnContext) -> None: + if self._timer is not None: + return - async def stop(self) -> None: - async with self._lock: - if not self._running: - return + logger.debug(f"Starting typing indicator with interval: {self._interval} ms") + func = self._on_timer(context) + self._timer = Timer(self._interval, func) + self._timer.start() + await func() + def stop(self) -> None: + if self._timer: logger.debug("Stopping typing indicator") - self._running = False - task = self._task - self._task = None + self._timer.cancel() + self._timer = None - # Cancel outside the lock to avoid blocking - if task and not task.done(): - task.cancel() + def _on_timer(self, context: TurnContext): + async def __call__(): try: - await task - except asyncio.CancelledError: - pass - - async def _typing_loop(self, context: TurnContext): - """Continuously send typing indicators at the specified interval.""" - try: - while True: - # Check running status under lock - async with self._lock: - if not self._running: - break - - try: - logger.debug("Sending typing activity") - await context.send_activity(Activity(type=ActivityTypes.typing)) - except Exception as e: - logger.error(f"Error sending typing activity: {e}") - async with self._lock: - self._running = False - break - - await asyncio.sleep(self._intervalSeconds) - except asyncio.CancelledError: - logger.debug("Typing indicator loop cancelled") + logger.debug("Sending typing activity") + await context.send_activity(Activity(type=ActivityTypes.typing)) + except Exception as e: + # TODO: Improve when adding logging + logger.error(f"Error sending typing activity: {e}") + self.stop() + + return __call__ From 20a5ad44fdf8b5aae962cf5321ce1f6d4e6fd0bc Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 6 Nov 2025 13:14:29 -0800 Subject: [PATCH 02/16] Adjusting TypingIndicator unit tests to fit new API --- .../hosting/core/app/typing_indicator.py | 3 +- .../hosting_core/app/test_typing_indicator.py | 42 +++++++++---------- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py index 592754b4..f300c689 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py @@ -21,10 +21,11 @@ class TypingIndicator: """ _interval: int - _timer: Optional[Timer] = None + _timer: Optional[Timer] def __init__(self, interval=10) -> None: self._interval = interval + self._timer = None async def start(self, context: TurnContext) -> None: if self._timer is not None: diff --git a/tests/hosting_core/app/test_typing_indicator.py b/tests/hosting_core/app/test_typing_indicator.py index c58c4ed4..1ead174b 100644 --- a/tests/hosting_core/app/test_typing_indicator.py +++ b/tests/hosting_core/app/test_typing_indicator.py @@ -23,11 +23,11 @@ async def send_activity(self, activity: Activity): @pytest.mark.asyncio async def test_start_sends_typing_activity(): context = StubTurnContext() - indicator = TypingIndicator(intervalSeconds=0.01) + indicator = TypingIndicator(interval=10) # 10ms interval await indicator.start(context) - await asyncio.sleep(0.03) - await indicator.stop() + await asyncio.sleep(0.05) # Wait 50ms to allow multiple typing activities + indicator.stop() assert len(context.sent_activities) >= 1 assert all( @@ -38,41 +38,39 @@ async def test_start_sends_typing_activity(): @pytest.mark.asyncio async def test_start_is_idempotent(): context = StubTurnContext() - indicator = TypingIndicator(intervalSeconds=0.01) + indicator = TypingIndicator(interval=10) await indicator.start(context) - first_task = indicator._task # noqa: SLF001 - accessing for test verification + first_timer = indicator._timer # noqa: SLF001 - accessing for test verification await indicator.start(context) - second_task = indicator._task # noqa: SLF001 + second_timer = indicator._timer # noqa: SLF001 - assert first_task is second_task + assert first_timer is second_timer - await indicator.stop() + indicator.stop() @pytest.mark.asyncio async def test_stop_without_start_is_noop(): indicator = TypingIndicator() - await indicator.stop() + indicator.stop() # stop() is now synchronous - assert indicator._task is None # noqa: SLF001 - assert indicator._running is False # noqa: SLF001 + assert indicator._timer is None # noqa: SLF001 @pytest.mark.asyncio -async def test_typing_loop_stops_on_send_error(): +async def test_typing_stops_on_send_error(): context = StubTurnContext(should_raise=True) - indicator = TypingIndicator(intervalSeconds=0.01) + indicator = TypingIndicator(interval=10) await indicator.start(context) - await asyncio.sleep(0.02) - - assert indicator._task is not None # noqa: SLF001 - await asyncio.wait_for(indicator._task, timeout=0.1) # Ensure loop exits - - assert indicator._running is False # noqa: SLF001 - assert indicator._task.done() # noqa: SLF001 - - await indicator.stop() + + # Wait a bit to allow the error to occur and timer to be cancelled + await asyncio.sleep(0.05) + + # The timer should be cancelled due to the error + assert indicator._timer is None # noqa: SLF001 + + indicator.stop() # Cleanup, should be safe even if already stopped \ No newline at end of file From 95218b13952c17b042d7e5d4b4fc28ada633d17a Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 6 Nov 2025 13:16:12 -0800 Subject: [PATCH 03/16] Formatting tests --- tests/hosting_core/app/test_typing_indicator.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/hosting_core/app/test_typing_indicator.py b/tests/hosting_core/app/test_typing_indicator.py index 1ead174b..d02673db 100644 --- a/tests/hosting_core/app/test_typing_indicator.py +++ b/tests/hosting_core/app/test_typing_indicator.py @@ -66,11 +66,11 @@ async def test_typing_stops_on_send_error(): indicator = TypingIndicator(interval=10) await indicator.start(context) - + # Wait a bit to allow the error to occur and timer to be cancelled await asyncio.sleep(0.05) - + # The timer should be cancelled due to the error assert indicator._timer is None # noqa: SLF001 - indicator.stop() # Cleanup, should be safe even if already stopped \ No newline at end of file + indicator.stop() # Cleanup, should be safe even if already stopped From 7c521c4c37fbcc308888538447dbbe803355291f Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 11 Nov 2025 06:39:59 -0800 Subject: [PATCH 04/16] Async version of typing indicator --- .../hosting/core/app/agent_application.py | 2 +- .../hosting/core/app/typing_indicator.py | 63 ++++++++++--------- 2 files changed, 33 insertions(+), 32 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index e0cb6de4..87b3518a 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -684,7 +684,7 @@ async def _on_turn(self, context: TurnContext): if context.activity.type != ActivityTypes.typing: if self._options.start_typing_timer: typing = TypingIndicator() - await typing.start(context) + typing.start(context) self._remove_mentions(context) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py index f300c689..c3902c43 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py @@ -4,10 +4,11 @@ """ from __future__ import annotations -import logging -from threading import Timer +import asyncio +import logging from typing import Optional +from datetime import datetime, timedelta from microsoft_agents.hosting.core import TurnContext from microsoft_agents.activity import Activity, ActivityTypes @@ -18,39 +19,39 @@ class TypingIndicator: """ Encapsulates the logic for sending "typing" activity to the user. + + Scoped to a single turn of conversation with the user. """ - _interval: int - _timer: Optional[Timer] + def __init__(self, context: TurnContext, interval: float =10.0) -> None: + self._context: TurnContext = context + self._interval: float = interval + self._task: Optional[asyncio.Task[None]] = None + + async def _run(self) -> None: + """Sends typing indicators at regular intervals.""" + + while self._context is not None: + await self._context.send_activity( + Activity(type=ActivityTypes.TYPING) + ) + await asyncio.sleep(self._interval) - def __init__(self, interval=10) -> None: - self._interval = interval - self._timer = None + def start(self) -> None: + """Starts sending typing indicators.""" - async def start(self, context: TurnContext) -> None: - if self._timer is not None: - return + if self._task is not None: + raise RuntimeError("Typing indicator is already running.") - logger.debug(f"Starting typing indicator with interval: {self._interval} ms") - func = self._on_timer(context) - self._timer = Timer(self._interval, func) - self._timer.start() - await func() + logger.debug("Starting typing indicator with interval: %s seconds", self._interval) + self._task = asyncio.create_task(self._run()) def stop(self) -> None: - if self._timer: - logger.debug("Stopping typing indicator") - self._timer.cancel() - self._timer = None - - def _on_timer(self, context: TurnContext): - async def __call__(): - try: - logger.debug("Sending typing activity") - await context.send_activity(Activity(type=ActivityTypes.typing)) - except Exception as e: - # TODO: Improve when adding logging - logger.error(f"Error sending typing activity: {e}") - self.stop() - - return __call__ + """Stops sending typing indicators.""" + + if self._task is None: + raise RuntimeError("Typing indicator is not running.") + + logger.debug("Stopping typing indicator") + self._task.cancel() + self._task = None \ No newline at end of file From 236c07acf67333d0d839943863af330cc171d81c Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 11 Nov 2025 07:22:56 -0800 Subject: [PATCH 05/16] Unit tests --- .../hosting/core/app/typing_indicator.py | 16 +- .../hosting_core/app/test_typing_indicator.py | 187 +++++++++++++++--- 2 files changed, 170 insertions(+), 33 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py index c3902c43..ceafd9ae 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py @@ -23,18 +23,16 @@ class TypingIndicator: Scoped to a single turn of conversation with the user. """ - def __init__(self, context: TurnContext, interval: float =10.0) -> None: + def __init__(self, context: TurnContext, interval: float = 10.0) -> None: self._context: TurnContext = context self._interval: float = interval self._task: Optional[asyncio.Task[None]] = None async def _run(self) -> None: """Sends typing indicators at regular intervals.""" - + while self._context is not None: - await self._context.send_activity( - Activity(type=ActivityTypes.TYPING) - ) + await self._context.send_activity(Activity(type=ActivityTypes.TYPING)) await asyncio.sleep(self._interval) def start(self) -> None: @@ -43,7 +41,9 @@ def start(self) -> None: if self._task is not None: raise RuntimeError("Typing indicator is already running.") - logger.debug("Starting typing indicator with interval: %s seconds", self._interval) + logger.debug( + "Starting typing indicator with interval: %s seconds", self._interval + ) self._task = asyncio.create_task(self._run()) def stop(self) -> None: @@ -51,7 +51,7 @@ def stop(self) -> None: if self._task is None: raise RuntimeError("Typing indicator is not running.") - + logger.debug("Stopping typing indicator") self._task.cancel() - self._task = None \ No newline at end of file + self._task = None diff --git a/tests/hosting_core/app/test_typing_indicator.py b/tests/hosting_core/app/test_typing_indicator.py index d02673db..14b3be83 100644 --- a/tests/hosting_core/app/test_typing_indicator.py +++ b/tests/hosting_core/app/test_typing_indicator.py @@ -1,8 +1,14 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + import asyncio import pytest from microsoft_agents.activity import Activity, ActivityTypes +from microsoft_agents.hosting.core import TurnContext from microsoft_agents.hosting.core.app.typing_indicator import TypingIndicator @@ -21,56 +27,187 @@ async def send_activity(self, activity: Activity): @pytest.mark.asyncio -async def test_start_sends_typing_activity(): +async def test_init_sets_context_and_interval(): + """Test that __init__ properly sets context and interval.""" context = StubTurnContext() - indicator = TypingIndicator(interval=10) # 10ms interval + interval = 5.0 + + indicator = TypingIndicator(context, interval) + + assert indicator._context is context + assert indicator._interval == interval + assert indicator._task is None + - await indicator.start(context) +@pytest.mark.asyncio +async def test_init_default_interval(): + """Test that __init__ uses default interval of 10.0 seconds.""" + context = StubTurnContext() + + indicator = TypingIndicator(context) + + assert indicator._interval == 10.0 + + +@pytest.mark.asyncio +async def test_start_sends_typing_activity(): + """Test that start() begins sending typing activities at regular intervals.""" + context = StubTurnContext() + indicator = TypingIndicator(context, interval=0.01) # 10ms interval + + indicator.start() await asyncio.sleep(0.05) # Wait 50ms to allow multiple typing activities indicator.stop() - - assert len(context.sent_activities) >= 1 + + # Should have sent at least 3 typing activities (50ms / 10ms = 5, but accounting for timing) + assert len(context.sent_activities) >= 3 assert all( - activity.type == ActivityTypes.typing for activity in context.sent_activities + activity.type == ActivityTypes.TYPING for activity in context.sent_activities ) @pytest.mark.asyncio -async def test_start_is_idempotent(): +async def test_start_creates_task(): + """Test that start() creates an asyncio task.""" context = StubTurnContext() - indicator = TypingIndicator(interval=10) + indicator = TypingIndicator(context) + + indicator.start() + + assert indicator._task is not None + assert isinstance(indicator._task, asyncio.Task) + + indicator.stop() - await indicator.start(context) - first_timer = indicator._timer # noqa: SLF001 - accessing for test verification - await indicator.start(context) - second_timer = indicator._timer # noqa: SLF001 +@pytest.mark.asyncio +async def test_start_raises_if_already_running(): + """Test that start() raises RuntimeError if already running.""" + context = StubTurnContext() + indicator = TypingIndicator(context) + + indicator.start() + + with pytest.raises(RuntimeError, match="Typing indicator is already running"): + indicator.start() + + indicator.stop() - assert first_timer is second_timer +@pytest.mark.asyncio +async def test_stop_cancels_task(): + """Test that stop() cancels the typing indicator task.""" + context = StubTurnContext() + indicator = TypingIndicator(context, interval=0.01) + + indicator.start() + task = indicator._task + await asyncio.sleep(0.02) # Let it run briefly + indicator.stop() + + assert indicator._task is None + assert task.cancelled() @pytest.mark.asyncio -async def test_stop_without_start_is_noop(): - indicator = TypingIndicator() +async def test_stop_raises_if_not_running(): + """Test that stop() raises RuntimeError if not running.""" + context = StubTurnContext() + indicator = TypingIndicator(context) + + with pytest.raises(RuntimeError, match="Typing indicator is not running"): + indicator.stop() - indicator.stop() # stop() is now synchronous - assert indicator._timer is None # noqa: SLF001 +@pytest.mark.asyncio +async def test_stop_prevents_further_typing_activities(): + """Test that stop() prevents further typing activities from being sent.""" + context = StubTurnContext() + indicator = TypingIndicator(context, interval=0.01) + + indicator.start() + await asyncio.sleep(0.025) # Let it run briefly + indicator.stop() + + count_before = len(context.sent_activities) + await asyncio.sleep(0.03) # Wait more time + count_after = len(context.sent_activities) + + assert count_before == count_after # No new activities after stop @pytest.mark.asyncio -async def test_typing_stops_on_send_error(): +async def test_typing_continues_on_send_error(): + """Test that the typing indicator handles send errors gracefully.""" context = StubTurnContext(should_raise=True) - indicator = TypingIndicator(interval=10) + indicator = TypingIndicator(context, interval=0.01) + + indicator.start() + await asyncio.sleep(0.03) # Wait for error to occur + + # Task should still exist but may be done/cancelled due to unhandled exception + # The implementation doesn't catch exceptions in _run, so the task will fail + assert indicator._task is not None + + # Cleanup + try: + indicator.stop() + except RuntimeError: + # May already be stopped due to exception + pass - await indicator.start(context) - # Wait a bit to allow the error to occur and timer to be cancelled - await asyncio.sleep(0.05) +@pytest.mark.asyncio +async def test_context_none_stops_loop(): + """Test that setting context to None would stop the loop (if implemented).""" + context = StubTurnContext() + indicator = TypingIndicator(context, interval=0.01) + + indicator.start() + await asyncio.sleep(0.015) # Let at least one activity send + + # Simulate context becoming None (though the current implementation checks while context) + indicator._context = None + await asyncio.sleep(0.02) + + # The loop should stop when context becomes None + indicator._task.cancel() + indicator._task = None - # The timer should be cancelled due to the error - assert indicator._timer is None # noqa: SLF001 - indicator.stop() # Cleanup, should be safe even if already stopped +@pytest.mark.asyncio +async def test_multiple_start_stop_cycles(): + """Test that the indicator can be started and stopped multiple times.""" + context = StubTurnContext() + indicator = TypingIndicator(context, interval=0.01) + + # First cycle + indicator.start() + await asyncio.sleep(0.02) + indicator.stop() + count_first = len(context.sent_activities) + + # Second cycle + indicator.start() + await asyncio.sleep(0.02) + indicator.stop() + count_second = len(context.sent_activities) + + assert count_second > count_first + + +@pytest.mark.asyncio +async def test_typing_activity_format(): + """Test that sent activities are properly formatted typing activities.""" + context = StubTurnContext() + indicator = TypingIndicator(context, interval=0.01) + + indicator.start() + await asyncio.sleep(0.015) # Wait for at least one activity + indicator.stop() + + assert len(context.sent_activities) >= 1 + for activity in context.sent_activities: + assert isinstance(activity, Activity) + assert activity.type == ActivityTypes.typing \ No newline at end of file From 241b676ee157933bf49d9de82dc9c0bfacb8c621 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 11 Nov 2025 07:42:36 -0800 Subject: [PATCH 06/16] Finalizing TypingIndicator tests --- .../hosting/core/app/typing_indicator.py | 6 +- .../hosting_core/app/test_typing_indicator.py | 83 +------------------ 2 files changed, 6 insertions(+), 83 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py index ceafd9ae..0b849f92 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py @@ -31,8 +31,10 @@ def __init__(self, context: TurnContext, interval: float = 10.0) -> None: async def _run(self) -> None: """Sends typing indicators at regular intervals.""" - while self._context is not None: - await self._context.send_activity(Activity(type=ActivityTypes.TYPING)) + running_task = self._task + + while running_task is self._task: + await self._context.send_activity(Activity(type=ActivityTypes.typing)) await asyncio.sleep(self._interval) def start(self) -> None: diff --git a/tests/hosting_core/app/test_typing_indicator.py b/tests/hosting_core/app/test_typing_indicator.py index 14b3be83..7a9b7893 100644 --- a/tests/hosting_core/app/test_typing_indicator.py +++ b/tests/hosting_core/app/test_typing_indicator.py @@ -24,31 +24,7 @@ async def send_activity(self, activity: Activity): raise RuntimeError("send_activity failure") self.sent_activities.append(activity) return None - - -@pytest.mark.asyncio -async def test_init_sets_context_and_interval(): - """Test that __init__ properly sets context and interval.""" - context = StubTurnContext() - interval = 5.0 - - indicator = TypingIndicator(context, interval) - assert indicator._context is context - assert indicator._interval == interval - assert indicator._task is None - - -@pytest.mark.asyncio -async def test_init_default_interval(): - """Test that __init__ uses default interval of 10.0 seconds.""" - context = StubTurnContext() - - indicator = TypingIndicator(context) - - assert indicator._interval == 10.0 - - @pytest.mark.asyncio async def test_start_sends_typing_activity(): """Test that start() begins sending typing activities at regular intervals.""" @@ -59,10 +35,11 @@ async def test_start_sends_typing_activity(): await asyncio.sleep(0.05) # Wait 50ms to allow multiple typing activities indicator.stop() + # Should have sent at least 3 typing activities (50ms / 10ms = 5, but accounting for timing) assert len(context.sent_activities) >= 3 assert all( - activity.type == ActivityTypes.TYPING for activity in context.sent_activities + activity.type == ActivityTypes.typing for activity in context.sent_activities ) @@ -94,22 +71,6 @@ async def test_start_raises_if_already_running(): indicator.stop() -@pytest.mark.asyncio -async def test_stop_cancels_task(): - """Test that stop() cancels the typing indicator task.""" - context = StubTurnContext() - indicator = TypingIndicator(context, interval=0.01) - - indicator.start() - task = indicator._task - await asyncio.sleep(0.02) # Let it run briefly - - indicator.stop() - - assert indicator._task is None - assert task.cancelled() - - @pytest.mark.asyncio async def test_stop_raises_if_not_running(): """Test that stop() raises RuntimeError if not running.""" @@ -136,46 +97,6 @@ async def test_stop_prevents_further_typing_activities(): assert count_before == count_after # No new activities after stop - -@pytest.mark.asyncio -async def test_typing_continues_on_send_error(): - """Test that the typing indicator handles send errors gracefully.""" - context = StubTurnContext(should_raise=True) - indicator = TypingIndicator(context, interval=0.01) - - indicator.start() - await asyncio.sleep(0.03) # Wait for error to occur - - # Task should still exist but may be done/cancelled due to unhandled exception - # The implementation doesn't catch exceptions in _run, so the task will fail - assert indicator._task is not None - - # Cleanup - try: - indicator.stop() - except RuntimeError: - # May already be stopped due to exception - pass - - -@pytest.mark.asyncio -async def test_context_none_stops_loop(): - """Test that setting context to None would stop the loop (if implemented).""" - context = StubTurnContext() - indicator = TypingIndicator(context, interval=0.01) - - indicator.start() - await asyncio.sleep(0.015) # Let at least one activity send - - # Simulate context becoming None (though the current implementation checks while context) - indicator._context = None - await asyncio.sleep(0.02) - - # The loop should stop when context becomes None - indicator._task.cancel() - indicator._task = None - - @pytest.mark.asyncio async def test_multiple_start_stop_cycles(): """Test that the indicator can be started and stopped multiple times.""" From 8f999680cc2b5204f6f0e6b92f7425c3ff440020 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 11 Nov 2025 07:43:46 -0800 Subject: [PATCH 07/16] Typing indicator construction fix --- .../microsoft_agents/hosting/core/app/agent_application.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index 87b3518a..afd4016e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -683,8 +683,8 @@ async def _on_turn(self, context: TurnContext): try: if context.activity.type != ActivityTypes.typing: if self._options.start_typing_timer: - typing = TypingIndicator() - typing.start(context) + typing = TypingIndicator(context, 10.0) + typing.start() self._remove_mentions(context) @@ -733,7 +733,7 @@ async def _on_turn(self, context: TurnContext): await self._on_error(context, err) finally: if typing: - await typing.stop() + typing.stop() def _remove_mentions(self, context: TurnContext): if ( From feb2c2b4f85720edc9d14beebfde260f523e985c Mon Sep 17 00:00:00 2001 From: rodrigobr-msft Date: Tue, 11 Nov 2025 08:23:42 -0800 Subject: [PATCH 08/16] Update libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../microsoft_agents/hosting/core/app/typing_indicator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py index 0b849f92..9181a2b3 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py @@ -8,7 +8,6 @@ import asyncio import logging from typing import Optional -from datetime import datetime, timedelta from microsoft_agents.hosting.core import TurnContext from microsoft_agents.activity import Activity, ActivityTypes From efe9e2461dd4af2380bdf0c4636220ccb10b09a1 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 11 Nov 2025 08:41:00 -0800 Subject: [PATCH 09/16] Another commit --- .../microsoft_agents/hosting/core/app/typing_indicator.py | 3 +-- tests/hosting_core/app/test_typing_indicator.py | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py index 0b849f92..f1ad287d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py @@ -8,7 +8,6 @@ import asyncio import logging from typing import Optional -from datetime import datetime, timedelta from microsoft_agents.hosting.core import TurnContext from microsoft_agents.activity import Activity, ActivityTypes @@ -56,4 +55,4 @@ def stop(self) -> None: logger.debug("Stopping typing indicator") self._task.cancel() - self._task = None + self._task = None \ No newline at end of file diff --git a/tests/hosting_core/app/test_typing_indicator.py b/tests/hosting_core/app/test_typing_indicator.py index 7a9b7893..014b0032 100644 --- a/tests/hosting_core/app/test_typing_indicator.py +++ b/tests/hosting_core/app/test_typing_indicator.py @@ -117,7 +117,6 @@ async def test_multiple_start_stop_cycles(): assert count_second > count_first - @pytest.mark.asyncio async def test_typing_activity_format(): """Test that sent activities are properly formatted typing activities.""" From 43f1ccf94cd8221964a8bc8fa348d3e757b50856 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 11 Nov 2025 08:43:33 -0800 Subject: [PATCH 10/16] Reformatting --- .../hosting/core/app/typing_indicator.py | 2 +- .../hosting_core/app/test_typing_indicator.py | 42 ++++++++++--------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py index f1ad287d..9181a2b3 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py @@ -55,4 +55,4 @@ def stop(self) -> None: logger.debug("Stopping typing indicator") self._task.cancel() - self._task = None \ No newline at end of file + self._task = None diff --git a/tests/hosting_core/app/test_typing_indicator.py b/tests/hosting_core/app/test_typing_indicator.py index 014b0032..3ad2475f 100644 --- a/tests/hosting_core/app/test_typing_indicator.py +++ b/tests/hosting_core/app/test_typing_indicator.py @@ -24,18 +24,18 @@ async def send_activity(self, activity: Activity): raise RuntimeError("send_activity failure") self.sent_activities.append(activity) return None - + + @pytest.mark.asyncio async def test_start_sends_typing_activity(): """Test that start() begins sending typing activities at regular intervals.""" context = StubTurnContext() indicator = TypingIndicator(context, interval=0.01) # 10ms interval - + indicator.start() await asyncio.sleep(0.05) # Wait 50ms to allow multiple typing activities indicator.stop() - - + # Should have sent at least 3 typing activities (50ms / 10ms = 5, but accounting for timing) assert len(context.sent_activities) >= 3 assert all( @@ -48,12 +48,12 @@ async def test_start_creates_task(): """Test that start() creates an asyncio task.""" context = StubTurnContext() indicator = TypingIndicator(context) - + indicator.start() - + assert indicator._task is not None assert isinstance(indicator._task, asyncio.Task) - + indicator.stop() @@ -62,12 +62,12 @@ async def test_start_raises_if_already_running(): """Test that start() raises RuntimeError if already running.""" context = StubTurnContext() indicator = TypingIndicator(context) - + indicator.start() - + with pytest.raises(RuntimeError, match="Typing indicator is already running"): indicator.start() - + indicator.stop() @@ -76,7 +76,7 @@ async def test_stop_raises_if_not_running(): """Test that stop() raises RuntimeError if not running.""" context = StubTurnContext() indicator = TypingIndicator(context) - + with pytest.raises(RuntimeError, match="Typing indicator is not running"): indicator.stop() @@ -86,48 +86,50 @@ async def test_stop_prevents_further_typing_activities(): """Test that stop() prevents further typing activities from being sent.""" context = StubTurnContext() indicator = TypingIndicator(context, interval=0.01) - + indicator.start() await asyncio.sleep(0.025) # Let it run briefly indicator.stop() - + count_before = len(context.sent_activities) await asyncio.sleep(0.03) # Wait more time count_after = len(context.sent_activities) - + assert count_before == count_after # No new activities after stop + @pytest.mark.asyncio async def test_multiple_start_stop_cycles(): """Test that the indicator can be started and stopped multiple times.""" context = StubTurnContext() indicator = TypingIndicator(context, interval=0.01) - + # First cycle indicator.start() await asyncio.sleep(0.02) indicator.stop() count_first = len(context.sent_activities) - + # Second cycle indicator.start() await asyncio.sleep(0.02) indicator.stop() count_second = len(context.sent_activities) - + assert count_second > count_first + @pytest.mark.asyncio async def test_typing_activity_format(): """Test that sent activities are properly formatted typing activities.""" context = StubTurnContext() indicator = TypingIndicator(context, interval=0.01) - + indicator.start() await asyncio.sleep(0.015) # Wait for at least one activity indicator.stop() - + assert len(context.sent_activities) >= 1 for activity in context.sent_activities: assert isinstance(activity, Activity) - assert activity.type == ActivityTypes.typing \ No newline at end of file + assert activity.type == ActivityTypes.typing From 700aa5ac7c8a0f298bb5ed82f79cf6fe65988eee Mon Sep 17 00:00:00 2001 From: rodrigobr-msft Date: Tue, 11 Nov 2025 09:58:04 -0800 Subject: [PATCH 11/16] Update tests/hosting_core/app/test_typing_indicator.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/hosting_core/app/test_typing_indicator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/hosting_core/app/test_typing_indicator.py b/tests/hosting_core/app/test_typing_indicator.py index 3ad2475f..d874510e 100644 --- a/tests/hosting_core/app/test_typing_indicator.py +++ b/tests/hosting_core/app/test_typing_indicator.py @@ -8,7 +8,6 @@ import pytest from microsoft_agents.activity import Activity, ActivityTypes -from microsoft_agents.hosting.core import TurnContext from microsoft_agents.hosting.core.app.typing_indicator import TypingIndicator From 44bc035c1abda2965ce27aa4b075cb06b262406f Mon Sep 17 00:00:00 2001 From: rodrigobr-msft Date: Tue, 11 Nov 2025 09:58:50 -0800 Subject: [PATCH 12/16] Update libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../hosting/core/app/typing_indicator.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py index 9181a2b3..49bb29e6 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py @@ -31,11 +31,13 @@ async def _run(self) -> None: """Sends typing indicators at regular intervals.""" running_task = self._task - - while running_task is self._task: - await self._context.send_activity(Activity(type=ActivityTypes.typing)) - await asyncio.sleep(self._interval) - + try: + while running_task is self._task: + await self._context.send_activity(Activity(type=ActivityTypes.typing)) + await asyncio.sleep(self._interval) + except asyncio.CancelledError: + # Task was cancelled, exit gracefully + pass def start(self) -> None: """Starts sending typing indicators.""" From 6856f3d202a9a2dba332fa353d5607a96dd227ac Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 11 Nov 2025 10:01:04 -0800 Subject: [PATCH 13/16] Addressing PR comments --- .../microsoft_agents/hosting/core/app/typing_indicator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py index 49bb29e6..19ab97e4 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py @@ -38,6 +38,7 @@ async def _run(self) -> None: except asyncio.CancelledError: # Task was cancelled, exit gracefully pass + def start(self) -> None: """Starts sending typing indicators.""" From eaf5e095eec17325637a66cd957061fae01d8559 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 11 Nov 2025 12:49:24 -0800 Subject: [PATCH 14/16] Addressing PR comment --- .../microsoft_agents/hosting/core/app/agent_application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index afd4016e..60875a0e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -683,7 +683,7 @@ async def _on_turn(self, context: TurnContext): try: if context.activity.type != ActivityTypes.typing: if self._options.start_typing_timer: - typing = TypingIndicator(context, 10.0) + typing = TypingIndicator(context) typing.start() self._remove_mentions(context) From 56dd761e92bb98aedf85ce0613ddbc48370cec1c Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 12 Nov 2025 07:27:57 -0800 Subject: [PATCH 15/16] Idempotency of start and stop and logging changes --- .../hosting/core/app/typing_indicator.py | 31 +++++++++++++++---- .../hosting_core/app/test_typing_indicator.py | 27 +++++++--------- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py index 19ab97e4..20dfa1e2 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py @@ -22,9 +22,16 @@ class TypingIndicator: Scoped to a single turn of conversation with the user. """ - def __init__(self, context: TurnContext, interval: float = 10.0) -> None: + def __init__(self, context: TurnContext, interval_seconds: float = 10.0) -> None: + """Initializes a new instance of the TypingIndicator class. + + :param context: The turn context. + :param interval_seconds: The interval in seconds between typing indicators. + """ + if interval_seconds <= 0: + raise ValueError("interval_seconds must be greater than 0") self._context: TurnContext = context - self._interval: float = interval + self._interval: float = interval_seconds self._task: Optional[asyncio.Task[None]] = None async def _run(self) -> None: @@ -43,10 +50,15 @@ def start(self) -> None: """Starts sending typing indicators.""" if self._task is not None: - raise RuntimeError("Typing indicator is already running.") + logger.warning( + "Typing indicator is already running for conversation %s", + self._context.activity.conversation.id, + ) logger.debug( - "Starting typing indicator with interval: %s seconds", self._interval + "Starting typing indicator with interval: %s seconds in conversation %s", + self._interval, + self._context.activity.conversation.id, ) self._task = asyncio.create_task(self._run()) @@ -54,8 +66,15 @@ def stop(self) -> None: """Stops sending typing indicators.""" if self._task is None: - raise RuntimeError("Typing indicator is not running.") + logger.warning( + "Typing indicator is not running for conversation %s", + self._context.activity.conversation.id, + ) + return - logger.debug("Stopping typing indicator") + logger.debug( + "Stopping typing indicator for conversation %s", + self._context.activity.conversation.id, + ) self._task.cancel() self._task = None diff --git a/tests/hosting_core/app/test_typing_indicator.py b/tests/hosting_core/app/test_typing_indicator.py index d874510e..70b9d75b 100644 --- a/tests/hosting_core/app/test_typing_indicator.py +++ b/tests/hosting_core/app/test_typing_indicator.py @@ -17,6 +17,7 @@ class StubTurnContext: def __init__(self, should_raise: bool = False) -> None: self.sent_activities = [] self.should_raise = should_raise + self.activity = Activity(type="text", conversation={"id": "test_convo"}) async def send_activity(self, activity: Activity): if self.should_raise: @@ -27,9 +28,9 @@ async def send_activity(self, activity: Activity): @pytest.mark.asyncio async def test_start_sends_typing_activity(): - """Test that start() begins sending typing activities at regular intervals.""" + """Test that start() begins sending typing activities at regular interval_secondss.""" context = StubTurnContext() - indicator = TypingIndicator(context, interval=0.01) # 10ms interval + indicator = TypingIndicator(context, interval_seconds=0.01) # 10ms interval_seconds indicator.start() await asyncio.sleep(0.05) # Wait 50ms to allow multiple typing activities @@ -57,34 +58,30 @@ async def test_start_creates_task(): @pytest.mark.asyncio -async def test_start_raises_if_already_running(): - """Test that start() raises RuntimeError if already running.""" +async def test_start_if_already_running(): + """Test that start() is idempotent if already running.""" context = StubTurnContext() indicator = TypingIndicator(context) indicator.start() - - with pytest.raises(RuntimeError, match="Typing indicator is already running"): - indicator.start() + indicator.start() indicator.stop() @pytest.mark.asyncio -async def test_stop_raises_if_not_running(): - """Test that stop() raises RuntimeError if not running.""" +async def test_stop_if_not_running(): + """Test that stop() is idempotent if not running.""" context = StubTurnContext() indicator = TypingIndicator(context) - - with pytest.raises(RuntimeError, match="Typing indicator is not running"): - indicator.stop() + indicator.stop() @pytest.mark.asyncio async def test_stop_prevents_further_typing_activities(): """Test that stop() prevents further typing activities from being sent.""" context = StubTurnContext() - indicator = TypingIndicator(context, interval=0.01) + indicator = TypingIndicator(context, interval_seconds=0.01) indicator.start() await asyncio.sleep(0.025) # Let it run briefly @@ -101,7 +98,7 @@ async def test_stop_prevents_further_typing_activities(): async def test_multiple_start_stop_cycles(): """Test that the indicator can be started and stopped multiple times.""" context = StubTurnContext() - indicator = TypingIndicator(context, interval=0.01) + indicator = TypingIndicator(context, interval_seconds=0.01) # First cycle indicator.start() @@ -122,7 +119,7 @@ async def test_multiple_start_stop_cycles(): async def test_typing_activity_format(): """Test that sent activities are properly formatted typing activities.""" context = StubTurnContext() - indicator = TypingIndicator(context, interval=0.01) + indicator = TypingIndicator(context, interval_seconds=0.01) indicator.start() await asyncio.sleep(0.015) # Wait for at least one activity From d36c9e20cbb0919043e046a9e2e630c242e3dddc Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 12 Nov 2025 08:24:30 -0800 Subject: [PATCH 16/16] Quick bug fix in start --- .../microsoft_agents/hosting/core/app/typing_indicator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py index 20dfa1e2..e3841e85 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py @@ -54,6 +54,7 @@ def start(self) -> None: "Typing indicator is already running for conversation %s", self._context.activity.conversation.id, ) + return logger.debug( "Starting typing indicator with interval: %s seconds in conversation %s",