-
Notifications
You must be signed in to change notification settings - Fork 56
TypingIndicator simplification #212
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5eef4df
282d75a
20a5ad4
95218b1
7c521c4
236c07a
241b676
8f99968
feb2c2b
efe9e24
5fadd08
43f1ccf
700aa5a
44bc035
6856f3d
eaf5e09
56dd761
ea305dc
d36c9e2
338cd5a
25065ba
e135ef6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,9 +4,9 @@ | |||||||||||
| """ | ||||||||||||
|
|
||||||||||||
| from __future__ import annotations | ||||||||||||
|
|
||||||||||||
| import asyncio | ||||||||||||
| import logging | ||||||||||||
|
|
||||||||||||
| from typing import Optional | ||||||||||||
|
|
||||||||||||
| from microsoft_agents.hosting.core import TurnContext | ||||||||||||
|
|
@@ -18,61 +18,64 @@ | |||||||||||
| class TypingIndicator: | ||||||||||||
| """ | ||||||||||||
| Encapsulates the logic for sending "typing" activity to the user. | ||||||||||||
|
|
||||||||||||
| Scoped to a single turn of conversation with 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() | ||||||||||||
| def __init__(self, context: TurnContext, interval_seconds: float = 10.0) -> None: | ||||||||||||
| """Initializes a new instance of the TypingIndicator class. | ||||||||||||
|
|
||||||||||||
| async def start(self, context: TurnContext) -> None: | ||||||||||||
| async with self._lock: | ||||||||||||
| if self._running: | ||||||||||||
| return | ||||||||||||
| :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_seconds | ||||||||||||
| self._task: Optional[asyncio.Task[None]] = None | ||||||||||||
|
|
||||||||||||
| 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 stop(self) -> None: | ||||||||||||
| async with self._lock: | ||||||||||||
| if not self._running: | ||||||||||||
| return | ||||||||||||
|
|
||||||||||||
| logger.debug("Stopping typing indicator") | ||||||||||||
| self._running = False | ||||||||||||
| task = self._task | ||||||||||||
| self._task = None | ||||||||||||
|
|
||||||||||||
| # Cancel outside the lock to avoid blocking | ||||||||||||
| if task and not task.done(): | ||||||||||||
| task.cancel() | ||||||||||||
| try: | ||||||||||||
| await task | ||||||||||||
| except asyncio.CancelledError: | ||||||||||||
| pass | ||||||||||||
|
|
||||||||||||
| async def _typing_loop(self, context: TurnContext): | ||||||||||||
| """Continuously send typing indicators at the specified interval.""" | ||||||||||||
| async def _run(self) -> None: | ||||||||||||
| """Sends typing indicators at regular intervals.""" | ||||||||||||
|
|
||||||||||||
| running_task = self._task | ||||||||||||
| 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) | ||||||||||||
| while running_task is self._task: | ||||||||||||
| await self._context.send_activity(Activity(type=ActivityTypes.typing)) | ||||||||||||
|
||||||||||||
| await self._context.send_activity(Activity(type=ActivityTypes.typing)) | |
| try: | |
| await self._context.send_activity(Activity(type=ActivityTypes.typing)) | |
| except Exception as e: | |
| logger.error("Error sending typing activity: %s", e) |
Copilot
AI
Nov 12, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's a race condition in the _run method. If stop() is called immediately after start() but before the task begins executing _run(), then when _run() starts:
- Line 40:
running_task = self._taskwill beNone(since stop() set it to None) - Line 42:
while None is self._taskevaluates towhile None is None, which isTrue - The loop will execute and try to send a typing activity even though stop() was already called
A more robust approach would be to initialize running_task to the current task object:
async def _run(self) -> None:
"""Sends typing indicators at regular intervals."""
running_task = asyncio.current_task()
try:
while running_task is self._task:
await self._context.send_activity(Activity(type=ActivityTypes.typing))
await asyncio.sleep(self._interval)
except asyncio.CancelledError:
passThis ensures the identity check works correctly even if stop() is called before _run() executes.
rodrigobr-msft marked this conversation as resolved.
Show resolved
Hide resolved
rodrigobr-msft marked this conversation as resolved.
Show resolved
Hide resolved
rodrigobr-msft marked this conversation as resolved.
Show resolved
Hide resolved
rodrigobr-msft marked this conversation as resolved.
Show resolved
Hide resolved
rodrigobr-msft marked this conversation as resolved.
Show resolved
Hide resolved
rodrigobr-msft marked this conversation as resolved.
Show resolved
Hide resolved
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,3 +1,8 @@ | ||||||
| """ | ||||||
| Copyright (c) Microsoft Corporation. All rights reserved. | ||||||
| Licensed under the MIT License. | ||||||
| """ | ||||||
|
|
||||||
| import asyncio | ||||||
|
|
||||||
| import pytest | ||||||
|
|
@@ -12,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: | ||||||
|
|
@@ -22,57 +28,104 @@ 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 interval_secondss.""" | ||||||
rodrigobr-msft marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
| context = StubTurnContext() | ||||||
| indicator = TypingIndicator(intervalSeconds=0.01) | ||||||
| indicator = TypingIndicator(context, interval_seconds=0.01) # 10ms interval_seconds | ||||||
|
||||||
| indicator = TypingIndicator(context, interval_seconds=0.01) # 10ms interval_seconds | |
| indicator = TypingIndicator(context, interval_seconds=0.01) # 10ms interval |
Uh oh!
There was an error while loading. Please reload this page.