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
Summary
Activity.textis annotatedstrbut defaults toNone:Constructing is fine (Pydantic does not validate defaults), but validating a payload where
textisnullraises. Combined withAgentApplication._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
Output on
1.3.0/pydantic 2.13.4:Root cause — two defects that only bite together
1. The annotation.
text: str = Nonemeans Pydantic v2 accepts a missingtext(defaults are not validated) but rejects an explicitnullon validation.2.
_remove_mentionsassigns the result unconditionally.TurnContext.remove_mention_textends inreturn activity.text, so with no mention to strip it returns the original value —None. The assignment is accepted becauseActivityhasvalidate_assignment = False, and it marks the field as SET. That is the part that hurts:_SignInState.store_item_to_json()dumps withexclude_unset=True, and an unset field would have been dropped.So any
type == messageactivity without atextkey (a file upload, an Adaptive Card submit) becomes a poisoned continuation activity as soon as the sign-in flow stores it.remove_recipient_mentiondefaults toTrue(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 catchesAttributeError, so theValidationErrorpropagates. 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()callsstorage.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
botidin the token-exchange URL) — so the SDK defect itself was never addressed, and it still reproduces on1.3.0.Two corrections to that report, offered because they explain why it persists:
"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, with1 validation error for Activity, not later withfor _SignInState. The null gets in through the unvalidated assignment above.Proposed fix
Same for any other field declared as a bare type with a
Nonedefault (relates_to,expiration, and similar inactivity.py).A narrower alternative would be to make
_remove_mentionsskip the assignment when the result isNone, but that leaves the annotation wrong for every other round-trip through storage.Please avoid
NonEmptyStringfortext: the natural workaround for this bug is to normaliseNoneto""before the SDK's turn pipeline runs, and amin_lengthconstraint would break that (cf. #48, where an emptytextfailed validation).Environment
microsoft-agents-activity/microsoft-agents-hosting-core1.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)pydantic2.13.4