Skip to content

Activity.text annotated str with None default: _remove_mentions poisons the persisted _SignInState (still in 1.3.0) #537

Description

@AlehopOrganizacion

Summary

Activity.text is annotated str but defaults to None:

# microsoft_agents/activity/activity.py
text: str = None
speak: str = None

Constructing is fine (Pydantic does not validate defaults), but validating a payload where text is null raises. Combined with AgentApplication._remove_mentions, this permanently breaks a user's conversation: the sign-in state is persisted with "text": null, and every subsequent turn fails reading it back.

Still present in 1.3.0.

Minimal reproduction — local, no Teams, no network

pip install microsoft-agents-hosting-core==1.3.0 microsoft-agents-activity==1.3.0
import logging
from microsoft_agents.activity import Activity
from microsoft_agents.hosting.core.app.oauth._sign_in_state import _SignInState
from microsoft_agents.hosting.core.turn_context import TurnContext

logging.disable(logging.CRITICAL)  # the SDK logs the ValidationError with a traceback

# A message activity with attachments and NO `text` key — what a file upload looks
# like. `text` is None but UNSET, so exclude_unset would drop it: safe so far.
activity = Activity.model_validate({
    "type": "message", "id": "1", "channelId": "msteams",
    "from": {"id": "user-1"}, "recipient": {"id": "bot-1"},
    "conversation": {"id": "conv-1"},
    "attachments": [{"contentType": "application/octet-stream", "name": "a.docx"}],
})
print("1. incoming     :", repr(activity.text), "set=", "text" in activity.model_fields_set)

# Exactly what AgentApplication._remove_mentions does for every type == message.
activity.text = TurnContext.remove_recipient_mention(activity)
print("2. after mention:", repr(activity.text), "set=", "text" in activity.model_fields_set)

state = _SignInState(active_handler_id="GRAPH")
state.continuation_activity = activity          # authorization.py does this
stored = state.store_item_to_json()
print("3. persisted    :", repr(stored["continuation_activity"].get("text", "<absent>")))

_SignInState.from_json_to_store_item(stored)    # raises

Output on 1.3.0 / pydantic 2.13.4:

1. incoming     : None set= False
2. after mention: None set= True
3. persisted    : None
ValidationError: 1 validation error for _SignInState
continuation_activity.text
  Input should be a valid string [type=string_type, input_value=None, input_type=NoneType]

Root cause — two defects that only bite together

1. The annotation. text: str = None means Pydantic v2 accepts a missing text (defaults are not validated) but rejects an explicit null on validation.

2. _remove_mentions assigns the result unconditionally.

# hosting/core/app/agent_application.py
def _remove_mentions(self, context: TurnContext):
    if (self.options.remove_recipient_mention
            and context.activity.type == ActivityTypes.message):
        context.activity.text = context.remove_recipient_mention(context.activity)

TurnContext.remove_mention_text ends in return activity.text, so with no mention to strip it returns the original value — None. The assignment is accepted because Activity has validate_assignment = False, and it marks the field as SET. That is the part that hurts: _SignInState.store_item_to_json() dumps with exclude_unset=True, and an unset field would have been dropped.

So any type == message activity without a text key (a file upload, an Adaptive Card submit) becomes a poisoned continuation activity as soon as the sign-in flow stores it.

remove_recipient_mention defaults to True (app_options.py), so this is the default configuration.

Impact: it does not self-heal

The poisoned state is keyed auth:_SignInState:{channel_id}:{user_id}. MemoryStorage.read() calls _SignInState.from_json_to_store_item() and only catches AttributeError, so the ValidationError propagates. Since the read always raises, the flow can neither progress nor clear the state — the user is stuck on every subsequent turn until the process restarts, and re-poisons on the next unauthenticated upload.

The only operation that recovers a stuck user is Authorization.sign_out(), because _delete_sign_in_state() calls storage.delete([key]) without reading first.

Relationship to #414

#414 reported the same annotation defect and proposed the same fix. It was closed as completed by its author, who had traced his symptom to an unrelated Entra configuration problem (a missing botid in the token-exchange URL) — so the SDK defect itself was never addressed, and it still reproduces on 1.3.0.

Two corrections to that report, offered because they explain why it persists:

  • Its stated mechanism — Teams sending "text": null, which Pydantic parses and marks as set — cannot be the path. Activity.model_validate({"type": "message", "text": None}) raises immediately, so such a payload would fail while deserialising the incoming activity, with 1 validation error for Activity, not later with for _SignInState. The null gets in through the unvalidated assignment above.
  • It states the bug is not reproducible locally. The snippet above reproduces it deterministically with no Teams deployment.

Proposed fix

text: Optional[str] = None
speak: Optional[str] = None

Same for any other field declared as a bare type with a None default (relates_to, expiration, and similar in activity.py).

A narrower alternative would be to make _remove_mentions skip the assignment when the result is None, but that leaves the annotation wrong for every other round-trip through storage.

Please avoid NonEmptyString for text: the natural workaround for this bug is to normalise None to "" before the SDK's turn pipeline runs, and a min_length constraint would break that (cf. #48, where an empty text failed validation).

Environment

  • microsoft-agents-activity / microsoft-agents-hosting-core 1.3.0 (annotation also verified unchanged in 1.0.0, 1.1.0, 1.2.0; full chain reproduced on 1.0.0 and 1.3.0)
  • pydantic 2.13.4
  • Python 3.14 (repro is version-independent)
  • Trigger in production: Teams file upload while the user is not yet authenticated

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions