diff --git a/examples/agent365/src/main.py b/examples/agent365/src/main.py index 355b3d1f8..78fe35fcb 100644 --- a/examples/agent365/src/main.py +++ b/examples/agent365/src/main.py @@ -7,7 +7,18 @@ import logging import re -from microsoft_teams.api import MessageActivity +from microsoft_teams.api import ( + AgenticUserDeletedActivity, + AgenticUserDisabledActivity, + AgenticUserEnabledActivity, + AgenticUserIdentityCreatedActivity, + AgenticUserIdentityUpdatedActivity, + AgenticUserManagerUpdatedActivity, + AgenticUserUndeletedActivity, + AgenticUserWorkloadOnboardingUpdatedActivity, + AgentLifecycleEventActivity, + MessageActivity, +) from microsoft_teams.api.activities.typing import TypingActivityInput from microsoft_teams.apps import ActivityContext, App @@ -17,6 +28,106 @@ app = App() +def _log_lifecycle_envelope(activity: AgentLifecycleEventActivity, handler_name: str) -> None: + logger.info( + "[Agent365 lifecycle:%s] name=%s value_type=%s event_type=%s channel_id=%s from=%s recipient_identity=%s", + handler_name, + activity.name, + activity.value_type, + activity.value.event_type, + activity.channel_id, + activity.from_.id, + activity.recipient.agentic_identity, + ) + logger.info( + "[Agent365 lifecycle:%s] tenant_id=%s agentic_user_id=%s app_instance_id=%s blueprint_id=%s version=%s", + handler_name, + activity.value.tenant_id, + activity.value.agentic_user_id, + activity.value.agentic_app_instance_id, + activity.value.agent_identity_blueprint_id, + activity.value.version, + ) + + +@app.on_agent_lifecycle +async def handle_agent_lifecycle(ctx: ActivityContext[AgentLifecycleEventActivity]) -> None: + """Log every Agent 365 agentLifecycle event.""" + _log_lifecycle_envelope(ctx.activity, "all") + await ctx.next() + + +@app.on_agentic_user_identity_created +async def handle_agentic_user_identity_created(ctx: ActivityContext[AgenticUserIdentityCreatedActivity]) -> None: + """Log an agentic user identity creation event.""" + activity = ctx.activity + _log_lifecycle_envelope(activity, "identity_created") + logger.info( + "[Agent365 lifecycle:identity_created] expiration_date_time=%s manager=%s", + activity.value.expiration_date_time, + activity.value.manager, + ) + + +@app.on_agentic_user_identity_updated +async def handle_agentic_user_identity_updated(ctx: ActivityContext[AgenticUserIdentityUpdatedActivity]) -> None: + """Log an agentic user identity property update event.""" + activity = ctx.activity + _log_lifecycle_envelope(activity, "identity_updated") + logger.info( + "[Agent365 lifecycle:identity_updated] updated_property=%s", + activity.value.updated_property, + ) + + +@app.on_agentic_user_manager_updated +async def handle_agentic_user_manager_updated(ctx: ActivityContext[AgenticUserManagerUpdatedActivity]) -> None: + """Log an agentic user manager update event.""" + activity = ctx.activity + _log_lifecycle_envelope(activity, "manager_updated") + logger.info("[Agent365 lifecycle:manager_updated] manager=%s", activity.value.manager) + + +@app.on_agentic_user_enabled +async def handle_agentic_user_enabled(ctx: ActivityContext[AgenticUserEnabledActivity]) -> None: + """Log an agentic user enabled event.""" + _log_lifecycle_envelope(ctx.activity, "enabled") + + +@app.on_agentic_user_disabled +async def handle_agentic_user_disabled(ctx: ActivityContext[AgenticUserDisabledActivity]) -> None: + """Log an agentic user disabled event.""" + _log_lifecycle_envelope(ctx.activity, "disabled") + + +@app.on_agentic_user_deleted +async def handle_agentic_user_deleted(ctx: ActivityContext[AgenticUserDeletedActivity]) -> None: + """Log an agentic user deleted event.""" + activity = ctx.activity + _log_lifecycle_envelope(activity, "deleted") + logger.info("[Agent365 lifecycle:deleted] deletion_reason=%s", activity.value.deletion_reason) + + +@app.on_agentic_user_undeleted +async def handle_agentic_user_undeleted(ctx: ActivityContext[AgenticUserUndeletedActivity]) -> None: + """Log an agentic user undeleted event.""" + _log_lifecycle_envelope(ctx.activity, "undeleted") + + +@app.on_agentic_user_workload_onboarding_updated +async def handle_agentic_user_workload_onboarding_updated( + ctx: ActivityContext[AgenticUserWorkloadOnboardingUpdatedActivity], +) -> None: + """Log an agentic user workload onboarding update event.""" + activity = ctx.activity + _log_lifecycle_envelope(activity, "workload_onboarding_updated") + logger.info( + "[Agent365 lifecycle:workload_onboarding_updated] workload_name=%s workload_onboarding_state=%s", + activity.value.workload_name, + activity.value.workload_onboarding_state, + ) + + @app.on_message_pattern(re.compile(r"hello|hi|greetings")) async def handle_greeting(ctx: ActivityContext[MessageActivity]) -> None: """Handle greeting messages using the inbound AgenticIdentity when present.""" diff --git a/packages/api/src/microsoft_teams/api/activities/event/__init__.py b/packages/api/src/microsoft_teams/api/activities/event/__init__.py index 56147f8e2..8d89421a9 100644 --- a/packages/api/src/microsoft_teams/api/activities/event/__init__.py +++ b/packages/api/src/microsoft_teams/api/activities/event/__init__.py @@ -7,6 +7,30 @@ from pydantic import Field +from .agent_lifecycle import ( + AgenticUserDeletedActivity, + AgenticUserDeletedValue, + AgenticUserDisabledActivity, + AgenticUserDisabledValue, + AgenticUserEnabledActivity, + AgenticUserEnabledValue, + AgenticUserIdentityCreatedActivity, + AgenticUserIdentityCreatedValue, + AgenticUserIdentityUpdatedActivity, + AgenticUserIdentityUpdatedValue, + AgenticUserManagerUpdatedActivity, + AgenticUserManagerUpdatedValue, + AgenticUserUndeletedActivity, + AgenticUserUndeletedValue, + AgenticUserWorkloadOnboardingUpdatedActivity, + AgenticUserWorkloadOnboardingUpdatedValue, + AgentLifecycleEventActivity, + AgentLifecycleEventActivityBase, + AgentLifecycleManager, + AgentLifecycleManagerRef, + AgentLifecycleUpdatedProperty, + AgentLifecycleValueBase, +) from .meeting_end import MeetingEndEventActivity from .meeting_participant import MeetingParticipantEventActivity from .meeting_participant_join import MeetingParticipantJoinEventActivity @@ -21,6 +45,7 @@ MeetingEndEventActivity, MeetingParticipantJoinEventActivity, MeetingParticipantLeaveEventActivity, + AgentLifecycleEventActivity, ], Field(discriminator="name"), ] @@ -33,4 +58,26 @@ "MeetingParticipantLeaveEventActivity", "ReadReceiptEventActivity", "EventActivity", + "AgentLifecycleEventActivity", + "AgentLifecycleEventActivityBase", + "AgenticUserIdentityCreatedActivity", + "AgenticUserIdentityUpdatedActivity", + "AgenticUserManagerUpdatedActivity", + "AgenticUserEnabledActivity", + "AgenticUserDisabledActivity", + "AgenticUserDeletedActivity", + "AgenticUserUndeletedActivity", + "AgenticUserWorkloadOnboardingUpdatedActivity", + "AgentLifecycleManager", + "AgentLifecycleManagerRef", + "AgentLifecycleUpdatedProperty", + "AgentLifecycleValueBase", + "AgenticUserIdentityCreatedValue", + "AgenticUserIdentityUpdatedValue", + "AgenticUserManagerUpdatedValue", + "AgenticUserEnabledValue", + "AgenticUserDisabledValue", + "AgenticUserDeletedValue", + "AgenticUserUndeletedValue", + "AgenticUserWorkloadOnboardingUpdatedValue", ] diff --git a/packages/api/src/microsoft_teams/api/activities/event/agent_lifecycle/__init__.py b/packages/api/src/microsoft_teams/api/activities/event/agent_lifecycle/__init__.py new file mode 100644 index 000000000..7ef17d8fb --- /dev/null +++ b/packages/api/src/microsoft_teams/api/activities/event/agent_lifecycle/__init__.py @@ -0,0 +1,56 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from .activity import ( + AgenticUserDeletedActivity, + AgenticUserDisabledActivity, + AgenticUserEnabledActivity, + AgenticUserIdentityCreatedActivity, + AgenticUserIdentityUpdatedActivity, + AgenticUserManagerUpdatedActivity, + AgenticUserUndeletedActivity, + AgenticUserWorkloadOnboardingUpdatedActivity, + AgentLifecycleEventActivity, + AgentLifecycleEventActivityBase, +) +from .value import ( + AgenticUserDeletedValue, + AgenticUserDisabledValue, + AgenticUserEnabledValue, + AgenticUserIdentityCreatedValue, + AgenticUserIdentityUpdatedValue, + AgenticUserManagerUpdatedValue, + AgenticUserUndeletedValue, + AgenticUserWorkloadOnboardingUpdatedValue, + AgentLifecycleManager, + AgentLifecycleManagerRef, + AgentLifecycleUpdatedProperty, + AgentLifecycleValueBase, +) + +__all__ = [ + "AgentLifecycleEventActivity", + "AgentLifecycleEventActivityBase", + "AgenticUserIdentityCreatedActivity", + "AgenticUserIdentityUpdatedActivity", + "AgenticUserManagerUpdatedActivity", + "AgenticUserEnabledActivity", + "AgenticUserDisabledActivity", + "AgenticUserDeletedActivity", + "AgenticUserUndeletedActivity", + "AgenticUserWorkloadOnboardingUpdatedActivity", + "AgentLifecycleManager", + "AgentLifecycleManagerRef", + "AgentLifecycleUpdatedProperty", + "AgentLifecycleValueBase", + "AgenticUserIdentityCreatedValue", + "AgenticUserIdentityUpdatedValue", + "AgenticUserManagerUpdatedValue", + "AgenticUserEnabledValue", + "AgenticUserDisabledValue", + "AgenticUserDeletedValue", + "AgenticUserUndeletedValue", + "AgenticUserWorkloadOnboardingUpdatedValue", +] diff --git a/packages/api/src/microsoft_teams/api/activities/event/agent_lifecycle/activity.py b/packages/api/src/microsoft_teams/api/activities/event/agent_lifecycle/activity.py new file mode 100644 index 000000000..6a7fcc2d8 --- /dev/null +++ b/packages/api/src/microsoft_teams/api/activities/event/agent_lifecycle/activity.py @@ -0,0 +1,119 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from typing import Annotated, Literal, Union + +from pydantic import Field + +from ....models import ActivityBase, CustomBaseModel +from .value import ( + AgenticUserDeletedValue, + AgenticUserDisabledValue, + AgenticUserEnabledValue, + AgenticUserIdentityCreatedValue, + AgenticUserIdentityUpdatedValue, + AgenticUserManagerUpdatedValue, + AgenticUserUndeletedValue, + AgenticUserWorkloadOnboardingUpdatedValue, +) + + +class AgentLifecycleEventActivityBase(ActivityBase, CustomBaseModel): + """Base for Agent 365 ``agentLifecycle`` event activities. + + These activities arrive from the ``System`` user on the ``agents`` channel with + ``type == "event"`` and ``name == "agentLifecycle"``. The ``value_type`` field + names the variant and ``value`` carries the typed payload. + """ + + type: Literal["event"] = "event" + + name: Literal["agentLifecycle"] = "agentLifecycle" + """The name of the operation associated with an event activity.""" + + +class AgenticUserIdentityCreatedActivity(AgentLifecycleEventActivityBase): + """Fired when an agentic user identity is created.""" + + value_type: Literal["AgenticUserIdentityCreated"] = "AgenticUserIdentityCreated" + value: AgenticUserIdentityCreatedValue + + +class AgenticUserIdentityUpdatedActivity(AgentLifecycleEventActivityBase): + """Fired when an agentic user identity property changes.""" + + value_type: Literal["AgenticUserIdentityUpdated"] = "AgenticUserIdentityUpdated" + value: AgenticUserIdentityUpdatedValue + + +class AgenticUserManagerUpdatedActivity(AgentLifecycleEventActivityBase): + """Fired when an agentic user's manager changes.""" + + value_type: Literal["AgenticUserManagerUpdated"] = "AgenticUserManagerUpdated" + value: AgenticUserManagerUpdatedValue + + +class AgenticUserEnabledActivity(AgentLifecycleEventActivityBase): + """Fired when an agentic user is enabled.""" + + value_type: Literal["AgenticUserEnabled"] = "AgenticUserEnabled" + value: AgenticUserEnabledValue + + +class AgenticUserDisabledActivity(AgentLifecycleEventActivityBase): + """Fired when an agentic user is disabled.""" + + value_type: Literal["AgenticUserDisabled"] = "AgenticUserDisabled" + value: AgenticUserDisabledValue + + +class AgenticUserDeletedActivity(AgentLifecycleEventActivityBase): + """Fired when an agentic user is deleted.""" + + value_type: Literal["AgenticUserDeleted"] = "AgenticUserDeleted" + value: AgenticUserDeletedValue + + +class AgenticUserUndeletedActivity(AgentLifecycleEventActivityBase): + """Fired when a previously deleted agentic user is restored.""" + + value_type: Literal["AgenticUserUndeleted"] = "AgenticUserUndeleted" + value: AgenticUserUndeletedValue + + +class AgenticUserWorkloadOnboardingUpdatedActivity(AgentLifecycleEventActivityBase): + """Fired when a workload onboarding state changes for an agentic user.""" + + value_type: Literal["AgenticUserWorkloadOnboardingUpdated"] = "AgenticUserWorkloadOnboardingUpdated" + value: AgenticUserWorkloadOnboardingUpdatedValue + + +AgentLifecycleEventActivity = Annotated[ + Union[ + AgenticUserIdentityCreatedActivity, + AgenticUserIdentityUpdatedActivity, + AgenticUserManagerUpdatedActivity, + AgenticUserEnabledActivity, + AgenticUserDisabledActivity, + AgenticUserDeletedActivity, + AgenticUserUndeletedActivity, + AgenticUserWorkloadOnboardingUpdatedActivity, + ], + Field(discriminator="value_type"), +] +"""Union of all Agent 365 ``agentLifecycle`` event activities, discriminated by ``valueType``.""" + +__all__ = [ + "AgentLifecycleEventActivityBase", + "AgenticUserIdentityCreatedActivity", + "AgenticUserIdentityUpdatedActivity", + "AgenticUserManagerUpdatedActivity", + "AgenticUserEnabledActivity", + "AgenticUserDisabledActivity", + "AgenticUserDeletedActivity", + "AgenticUserUndeletedActivity", + "AgenticUserWorkloadOnboardingUpdatedActivity", + "AgentLifecycleEventActivity", +] diff --git a/packages/api/src/microsoft_teams/api/activities/event/agent_lifecycle/value.py b/packages/api/src/microsoft_teams/api/activities/event/agent_lifecycle/value.py new file mode 100644 index 000000000..dbfd75155 --- /dev/null +++ b/packages/api/src/microsoft_teams/api/activities/event/agent_lifecycle/value.py @@ -0,0 +1,143 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from datetime import datetime +from typing import Literal, Optional + +from ....models import CustomBaseModel + + +class AgentLifecycleManager(CustomBaseModel): + """Manager profile carried by the ``AgenticUserIdentityCreated`` event.""" + + user_id: Optional[str] = None + """The Entra object ID of the manager.""" + + email: Optional[str] = None + """The manager's email address.""" + + display_name: Optional[str] = None + """The manager's display name.""" + + +class AgentLifecycleManagerRef(CustomBaseModel): + """Manager reference carried by the ``AgenticUserManagerUpdated`` event.""" + + manager_id: Optional[str] = None + """The Entra object ID of the manager.""" + + +class AgentLifecycleUpdatedProperty(CustomBaseModel): + """A single property change carried by the ``AgenticUserIdentityUpdated`` event.""" + + property_name: str + """The name of the property that changed (e.g. ``Mail``, ``Alias``, ``UserPrincipalName``).""" + + property_value: Optional[str] = None + """The new value of the property.""" + + +class AgentLifecycleValueBase(CustomBaseModel): + """Fields shared by every agentLifecycle event payload.""" + + tenant_id: Optional[str] = None + """The tenant the agentic user belongs to.""" + + agentic_user_id: Optional[str] = None + """The Agent ID user-shaped identity object ID.""" + + agentic_app_instance_id: Optional[str] = None + """The concrete agent app instance ID.""" + + agent_identity_blueprint_id: Optional[str] = None + """The agent identity blueprint app ID.""" + + version: Optional[int] = None + """Monotonic version of the agentic user state, when provided by the service.""" + + +class AgenticUserIdentityCreatedValue(AgentLifecycleValueBase): + """Payload for the ``agenticUserIdentityCreated`` event.""" + + event_type: Literal["agenticUserIdentityCreated"] = "agenticUserIdentityCreated" + + manager: Optional[AgentLifecycleManager] = None + """The manager assigned to the agentic user at creation.""" + + expiration_date_time: Optional[datetime] = None + """When the agentic user identity expires.""" + + +class AgenticUserIdentityUpdatedValue(AgentLifecycleValueBase): + """Payload for the ``agenticUserIdentityUpdated`` event.""" + + event_type: Literal["agenticUserIdentityUpdated"] = "agenticUserIdentityUpdated" + + updated_property: AgentLifecycleUpdatedProperty + """The property that changed.""" + + +class AgenticUserManagerUpdatedValue(AgentLifecycleValueBase): + """Payload for the ``agenticUserManagerUpdated`` event.""" + + event_type: Literal["agenticUserManagerUpdated"] = "agenticUserManagerUpdated" + + manager: Optional[AgentLifecycleManagerRef] = None + """The new manager reference. Absent when the manager was removed.""" + + +class AgenticUserEnabledValue(AgentLifecycleValueBase): + """Payload for the ``agenticUserEnabled`` event.""" + + event_type: Literal["agenticUserEnabled"] = "agenticUserEnabled" + + +class AgenticUserDisabledValue(AgentLifecycleValueBase): + """Payload for the ``agenticUserDisabled`` event.""" + + event_type: Literal["agenticUserDisabled"] = "agenticUserDisabled" + + +class AgenticUserDeletedValue(AgentLifecycleValueBase): + """Payload for the ``agenticUserDeleted`` event.""" + + event_type: Literal["agenticUserDeleted"] = "agenticUserDeleted" + + deletion_reason: Optional[str] = None + """The reason the agentic user was deleted (e.g. ``UserSoftDelete``, ``UserHardDelete``).""" + + +class AgenticUserUndeletedValue(AgentLifecycleValueBase): + """Payload for the ``agenticUserUndeleted`` event.""" + + event_type: Literal["agenticUserUndeleted"] = "agenticUserUndeleted" + + +class AgenticUserWorkloadOnboardingUpdatedValue(AgentLifecycleValueBase): + """Payload for the ``agenticUserWorkloadOnboardingUpdated`` event.""" + + event_type: Literal["agenticUserWorkloadOnboardingUpdated"] = "agenticUserWorkloadOnboardingUpdated" + + workload_name: Optional[str] = None + """The workload being onboarded (e.g. ``Teams``).""" + + workload_onboarding_state: Optional[str] = None + """The onboarding state for the workload (e.g. ``succeeded``).""" + + +__all__ = [ + "AgentLifecycleManager", + "AgentLifecycleManagerRef", + "AgentLifecycleUpdatedProperty", + "AgentLifecycleValueBase", + "AgenticUserIdentityCreatedValue", + "AgenticUserIdentityUpdatedValue", + "AgenticUserManagerUpdatedValue", + "AgenticUserEnabledValue", + "AgenticUserDisabledValue", + "AgenticUserDeletedValue", + "AgenticUserUndeletedValue", + "AgenticUserWorkloadOnboardingUpdatedValue", +] diff --git a/packages/api/tests/unit/test_agent_lifecycle_event.py b/packages/api/tests/unit/test_agent_lifecycle_event.py new file mode 100644 index 000000000..c836b482a --- /dev/null +++ b/packages/api/tests/unit/test_agent_lifecycle_event.py @@ -0,0 +1,169 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" +# pyright: basic + +from typing import Any, Dict + +import pytest +from microsoft_teams.api.activities import ActivityTypeAdapter +from microsoft_teams.api.activities.event.agent_lifecycle import ( + AgenticUserDeletedActivity, + AgenticUserDisabledActivity, + AgenticUserEnabledActivity, + AgenticUserIdentityCreatedActivity, + AgenticUserIdentityUpdatedActivity, + AgenticUserManagerUpdatedActivity, + AgenticUserUndeletedActivity, + AgenticUserWorkloadOnboardingUpdatedActivity, +) + +TENANT_ID = "3f3d1cea-7a18-41af-872b-cfbbd5140984" +AGENTIC_USER_ID = "08aa8591-53f1-4fc6-bfb6-314ba3c3ee5c" +AGENTIC_APP_INSTANCE_ID = "231c6c9d-2ccf-41f0-ab44-304c4d5033c9" +BLUEPRINT_ID = "88102a3f-0f37-46bf-80f6-8b4650d10c46" + + +def _envelope(value: Dict[str, Any], value_type: str) -> Dict[str, Any]: + return { + "recipient": { + "agenticUserId": AGENTIC_USER_ID, + "agenticAppId": AGENTIC_APP_INSTANCE_ID, + "agenticAppBlueprintId": BLUEPRINT_ID, + "callbackUri": "https://example.test/api/messages", + "tenantId": TENANT_ID, + "role": "agenticUser", + "id": AGENTIC_USER_ID, + }, + "type": "event", + "id": "activity-id", + "timestamp": "2026-06-29T00:00:00Z", + "serviceUrl": "https://smba.trafficmanager.net/amer/tenant/", + "channelId": "agents", + "from": {"id": "system", "name": "System", "tenantId": TENANT_ID}, + "conversation": {"tenantId": TENANT_ID, "id": "conversation-id", "topic": None}, + "channelData": {"tenant": {"id": TENANT_ID}, "productContext": None}, + "valueType": value_type, + "value": value, + "name": "agentLifecycle", + } + + +def _common(event_type: str) -> Dict[str, Any]: + return { + "tenantId": TENANT_ID, + "agenticUserId": AGENTIC_USER_ID, + "agenticAppInstanceId": AGENTIC_APP_INSTANCE_ID, + "agentIdentityBlueprintId": BLUEPRINT_ID, + "eventType": event_type, + } + + +@pytest.mark.unit +class TestAgentLifecycleEventParsing: + def test_identity_created(self) -> None: + value = { + "expirationDateTime": "0001-01-01T00:00:00+00:00", + "manager": { + "displayName": None, + "userId": "3c22b565-74f3-48b0-aa18-1dc03b8ec270", + "email": "manager@example.test", + }, + **_common("agenticUserIdentityCreated"), + } + activity = ActivityTypeAdapter.validate_python(_envelope(value, "AgenticUserIdentityCreated")) + + assert isinstance(activity, AgenticUserIdentityCreatedActivity) + assert activity.name == "agentLifecycle" + assert activity.value_type == "AgenticUserIdentityCreated" + assert activity.value.agentic_user_id == AGENTIC_USER_ID + assert activity.value.manager is not None + assert activity.value.manager.user_id == "3c22b565-74f3-48b0-aa18-1dc03b8ec270" + assert activity.value.manager.email == "manager@example.test" + + @pytest.mark.parametrize( + "property_name,property_value", + [ + ("Mail", "newinstance4@teamssdk.onmicrosoft.com"), + ("Alias", "newinstance4"), + ("UserPrincipalName", "newinstance4@teamssdk.onmicrosoft.com"), + ], + ) + def test_identity_updated(self, property_name: str, property_value: str) -> None: + value = { + "updatedProperty": {"propertyName": property_name, "propertyValue": property_value}, + **_common("agenticUserIdentityUpdated"), + "version": 4, + } + activity = ActivityTypeAdapter.validate_python(_envelope(value, "AgenticUserIdentityUpdated")) + + assert isinstance(activity, AgenticUserIdentityUpdatedActivity) + assert activity.value.updated_property.property_name == property_name + assert activity.value.updated_property.property_value == property_value + assert activity.value.version == 4 + + def test_manager_updated(self) -> None: + value = { + "manager": {"managerId": "3c22b565-74f3-48b0-aa18-1dc03b8ec270"}, + **_common("agenticUserManagerUpdated"), + "version": 6, + } + activity = ActivityTypeAdapter.validate_python(_envelope(value, "AgenticUserManagerUpdated")) + + assert isinstance(activity, AgenticUserManagerUpdatedActivity) + assert activity.value.manager is not None + assert activity.value.manager.manager_id == "3c22b565-74f3-48b0-aa18-1dc03b8ec270" + assert activity.value.version == 6 + + def test_enabled(self) -> None: + value = {**_common("agenticUserEnabled"), "version": 6} + activity = ActivityTypeAdapter.validate_python(_envelope(value, "AgenticUserEnabled")) + + assert isinstance(activity, AgenticUserEnabledActivity) + assert activity.value.version == 6 + + def test_disabled(self) -> None: + value = {**_common("agenticUserDisabled"), "version": 7} + activity = ActivityTypeAdapter.validate_python(_envelope(value, "AgenticUserDisabled")) + + assert isinstance(activity, AgenticUserDisabledActivity) + + def test_deleted(self) -> None: + value = {**_common("agenticUserDeleted"), "deletionReason": "UserSoftDelete", "version": 8} + activity = ActivityTypeAdapter.validate_python(_envelope(value, "AgenticUserDeleted")) + + assert isinstance(activity, AgenticUserDeletedActivity) + assert activity.value.deletion_reason == "UserSoftDelete" + + def test_undeleted(self) -> None: + value = {**_common("agenticUserUndeleted"), "version": 9} + activity = ActivityTypeAdapter.validate_python(_envelope(value, "AgenticUserUndeleted")) + + assert isinstance(activity, AgenticUserUndeletedActivity) + + def test_workload_onboarding_updated(self) -> None: + value = { + "workloadName": "Teams", + "workloadOnboardingState": "succeeded", + **_common("agenticUserWorkloadOnboardingUpdated"), + } + activity = ActivityTypeAdapter.validate_python(_envelope(value, "AgenticUserWorkloadOnboardingUpdated")) + + assert isinstance(activity, AgenticUserWorkloadOnboardingUpdatedActivity) + assert activity.value.workload_name == "Teams" + assert activity.value.workload_onboarding_state == "succeeded" + + def test_round_trip_serialization_uses_camel_case(self) -> None: + value = { + "manager": {"managerId": "3c22b565-74f3-48b0-aa18-1dc03b8ec270"}, + **_common("agenticUserManagerUpdated"), + "version": 6, + } + activity = ActivityTypeAdapter.validate_python(_envelope(value, "AgenticUserManagerUpdated")) + dumped = activity.model_dump(by_alias=True, exclude_none=True) + + assert dumped["name"] == "agentLifecycle" + assert dumped["valueType"] == "AgenticUserManagerUpdated" + assert dumped["value"]["agenticAppInstanceId"] == AGENTIC_APP_INSTANCE_ID + assert dumped["value"]["manager"]["managerId"] == "3c22b565-74f3-48b0-aa18-1dc03b8ec270" diff --git a/packages/apps/src/microsoft_teams/apps/routing/activity_route_configs.py b/packages/apps/src/microsoft_teams/apps/routing/activity_route_configs.py index 3be9917d5..b190f6862 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_route_configs.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_route_configs.py @@ -8,6 +8,14 @@ from microsoft_teams.api import ( ActivityBase, + AgenticUserDeletedActivity, + AgenticUserDisabledActivity, + AgenticUserEnabledActivity, + AgenticUserIdentityCreatedActivity, + AgenticUserIdentityUpdatedActivity, + AgenticUserManagerUpdatedActivity, + AgenticUserUndeletedActivity, + AgenticUserWorkloadOnboardingUpdatedActivity, CommandResultActivity, CommandSendActivity, ConfigFetchInvokeActivity, @@ -66,6 +74,14 @@ def _conversation_update_has_event_type(activity: ActivityBase, event_type: str) ) +def _is_agent_lifecycle(activity: ActivityBase) -> bool: + return activity.type == "event" and getattr(activity, "name", None) == "agentLifecycle" + + +def _is_agent_lifecycle_variant(activity: ActivityBase, value_type: str) -> bool: + return _is_agent_lifecycle(activity) and getattr(activity, "value_type", None) == value_type + + @dataclass(frozen=True) class ActivityConfig: """Configuration for an activity handler.""" @@ -287,6 +303,70 @@ class ActivityConfig: and cast(EventActivity, activity).name == "application/vnd.microsoft.meetingParticipantLeave", output_model=None, ), + # Agent 365 agentLifecycle event activities + "agent_lifecycle": ActivityConfig( + name="agent_lifecycle", + method_name="on_agent_lifecycle", + input_model="AgentLifecycleEventActivity", + selector=_is_agent_lifecycle, + output_model=None, + ), + "agentic_user_identity_created": ActivityConfig( + name="agentic_user_identity_created", + method_name="on_agentic_user_identity_created", + input_model=AgenticUserIdentityCreatedActivity, + selector=lambda activity: _is_agent_lifecycle_variant(activity, "AgenticUserIdentityCreated"), + output_model=None, + ), + "agentic_user_identity_updated": ActivityConfig( + name="agentic_user_identity_updated", + method_name="on_agentic_user_identity_updated", + input_model=AgenticUserIdentityUpdatedActivity, + selector=lambda activity: _is_agent_lifecycle_variant(activity, "AgenticUserIdentityUpdated"), + output_model=None, + ), + "agentic_user_manager_updated": ActivityConfig( + name="agentic_user_manager_updated", + method_name="on_agentic_user_manager_updated", + input_model=AgenticUserManagerUpdatedActivity, + selector=lambda activity: _is_agent_lifecycle_variant(activity, "AgenticUserManagerUpdated"), + output_model=None, + ), + "agentic_user_enabled": ActivityConfig( + name="agentic_user_enabled", + method_name="on_agentic_user_enabled", + input_model=AgenticUserEnabledActivity, + selector=lambda activity: _is_agent_lifecycle_variant(activity, "AgenticUserEnabled"), + output_model=None, + ), + "agentic_user_disabled": ActivityConfig( + name="agentic_user_disabled", + method_name="on_agentic_user_disabled", + input_model=AgenticUserDisabledActivity, + selector=lambda activity: _is_agent_lifecycle_variant(activity, "AgenticUserDisabled"), + output_model=None, + ), + "agentic_user_deleted": ActivityConfig( + name="agentic_user_deleted", + method_name="on_agentic_user_deleted", + input_model=AgenticUserDeletedActivity, + selector=lambda activity: _is_agent_lifecycle_variant(activity, "AgenticUserDeleted"), + output_model=None, + ), + "agentic_user_undeleted": ActivityConfig( + name="agentic_user_undeleted", + method_name="on_agentic_user_undeleted", + input_model=AgenticUserUndeletedActivity, + selector=lambda activity: _is_agent_lifecycle_variant(activity, "AgenticUserUndeleted"), + output_model=None, + ), + "agentic_user_workload_onboarding_updated": ActivityConfig( + name="agentic_user_workload_onboarding_updated", + method_name="on_agentic_user_workload_onboarding_updated", + input_model=AgenticUserWorkloadOnboardingUpdatedActivity, + selector=lambda activity: _is_agent_lifecycle_variant(activity, "AgenticUserWorkloadOnboardingUpdated"), + output_model=None, + ), # Invoke Activities with specific names and response types "config.open": ActivityConfig( name="config.open", diff --git a/packages/apps/src/microsoft_teams/apps/routing/generated_handlers.py b/packages/apps/src/microsoft_teams/apps/routing/generated_handlers.py index 2636969b2..c0e94d498 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/generated_handlers.py +++ b/packages/apps/src/microsoft_teams/apps/routing/generated_handlers.py @@ -15,6 +15,15 @@ from microsoft_teams.api.activities import ( Activity, AdaptiveCardInvokeActivity, + AgenticUserDeletedActivity, + AgenticUserDisabledActivity, + AgenticUserEnabledActivity, + AgenticUserIdentityCreatedActivity, + AgenticUserIdentityUpdatedActivity, + AgenticUserManagerUpdatedActivity, + AgenticUserUndeletedActivity, + AgenticUserWorkloadOnboardingUpdatedActivity, + AgentLifecycleEventActivity, CommandResultActivity, CommandSendActivity, ConfigFetchInvokeActivity, @@ -742,6 +751,278 @@ def decorator( return decorator(handler) return decorator + @overload + def on_agent_lifecycle( + self, handler: BasicHandler[AgentLifecycleEventActivity] + ) -> BasicHandler[AgentLifecycleEventActivity]: ... + + @overload + def on_agent_lifecycle( + self, + ) -> Callable[[BasicHandler[AgentLifecycleEventActivity]], BasicHandler[AgentLifecycleEventActivity]]: ... + + def on_agent_lifecycle( + self, handler: Optional[BasicHandler[AgentLifecycleEventActivity]] = None + ) -> BasicHandlerUnion[AgentLifecycleEventActivity]: + """Register a agent_lifecycle activity handler.""" + + def decorator(func: BasicHandler[AgentLifecycleEventActivity]) -> BasicHandler[AgentLifecycleEventActivity]: + validate_handler_type( + func, AgentLifecycleEventActivity, "on_agent_lifecycle", "AgentLifecycleEventActivity" + ) + config = ACTIVITY_ROUTES["agent_lifecycle"] + self.router.add_handler(config.selector, func) + return func + + if handler is not None: + return decorator(handler) + return decorator + + @overload + def on_agentic_user_identity_created( + self, handler: BasicHandler[AgenticUserIdentityCreatedActivity] + ) -> BasicHandler[AgenticUserIdentityCreatedActivity]: ... + + @overload + def on_agentic_user_identity_created( + self, + ) -> Callable[ + [BasicHandler[AgenticUserIdentityCreatedActivity]], BasicHandler[AgenticUserIdentityCreatedActivity] + ]: ... + + def on_agentic_user_identity_created( + self, handler: Optional[BasicHandler[AgenticUserIdentityCreatedActivity]] = None + ) -> BasicHandlerUnion[AgenticUserIdentityCreatedActivity]: + """Register a agentic_user_identity_created activity handler.""" + + def decorator( + func: BasicHandler[AgenticUserIdentityCreatedActivity], + ) -> BasicHandler[AgenticUserIdentityCreatedActivity]: + validate_handler_type( + func, + AgenticUserIdentityCreatedActivity, + "on_agentic_user_identity_created", + "AgenticUserIdentityCreatedActivity", + ) + config = ACTIVITY_ROUTES["agentic_user_identity_created"] + self.router.add_handler(config.selector, func) + return func + + if handler is not None: + return decorator(handler) + return decorator + + @overload + def on_agentic_user_identity_updated( + self, handler: BasicHandler[AgenticUserIdentityUpdatedActivity] + ) -> BasicHandler[AgenticUserIdentityUpdatedActivity]: ... + + @overload + def on_agentic_user_identity_updated( + self, + ) -> Callable[ + [BasicHandler[AgenticUserIdentityUpdatedActivity]], BasicHandler[AgenticUserIdentityUpdatedActivity] + ]: ... + + def on_agentic_user_identity_updated( + self, handler: Optional[BasicHandler[AgenticUserIdentityUpdatedActivity]] = None + ) -> BasicHandlerUnion[AgenticUserIdentityUpdatedActivity]: + """Register a agentic_user_identity_updated activity handler.""" + + def decorator( + func: BasicHandler[AgenticUserIdentityUpdatedActivity], + ) -> BasicHandler[AgenticUserIdentityUpdatedActivity]: + validate_handler_type( + func, + AgenticUserIdentityUpdatedActivity, + "on_agentic_user_identity_updated", + "AgenticUserIdentityUpdatedActivity", + ) + config = ACTIVITY_ROUTES["agentic_user_identity_updated"] + self.router.add_handler(config.selector, func) + return func + + if handler is not None: + return decorator(handler) + return decorator + + @overload + def on_agentic_user_manager_updated( + self, handler: BasicHandler[AgenticUserManagerUpdatedActivity] + ) -> BasicHandler[AgenticUserManagerUpdatedActivity]: ... + + @overload + def on_agentic_user_manager_updated( + self, + ) -> Callable[ + [BasicHandler[AgenticUserManagerUpdatedActivity]], BasicHandler[AgenticUserManagerUpdatedActivity] + ]: ... + + def on_agentic_user_manager_updated( + self, handler: Optional[BasicHandler[AgenticUserManagerUpdatedActivity]] = None + ) -> BasicHandlerUnion[AgenticUserManagerUpdatedActivity]: + """Register a agentic_user_manager_updated activity handler.""" + + def decorator( + func: BasicHandler[AgenticUserManagerUpdatedActivity], + ) -> BasicHandler[AgenticUserManagerUpdatedActivity]: + validate_handler_type( + func, + AgenticUserManagerUpdatedActivity, + "on_agentic_user_manager_updated", + "AgenticUserManagerUpdatedActivity", + ) + config = ACTIVITY_ROUTES["agentic_user_manager_updated"] + self.router.add_handler(config.selector, func) + return func + + if handler is not None: + return decorator(handler) + return decorator + + @overload + def on_agentic_user_enabled( + self, handler: BasicHandler[AgenticUserEnabledActivity] + ) -> BasicHandler[AgenticUserEnabledActivity]: ... + + @overload + def on_agentic_user_enabled( + self, + ) -> Callable[[BasicHandler[AgenticUserEnabledActivity]], BasicHandler[AgenticUserEnabledActivity]]: ... + + def on_agentic_user_enabled( + self, handler: Optional[BasicHandler[AgenticUserEnabledActivity]] = None + ) -> BasicHandlerUnion[AgenticUserEnabledActivity]: + """Register a agentic_user_enabled activity handler.""" + + def decorator(func: BasicHandler[AgenticUserEnabledActivity]) -> BasicHandler[AgenticUserEnabledActivity]: + validate_handler_type( + func, AgenticUserEnabledActivity, "on_agentic_user_enabled", "AgenticUserEnabledActivity" + ) + config = ACTIVITY_ROUTES["agentic_user_enabled"] + self.router.add_handler(config.selector, func) + return func + + if handler is not None: + return decorator(handler) + return decorator + + @overload + def on_agentic_user_disabled( + self, handler: BasicHandler[AgenticUserDisabledActivity] + ) -> BasicHandler[AgenticUserDisabledActivity]: ... + + @overload + def on_agentic_user_disabled( + self, + ) -> Callable[[BasicHandler[AgenticUserDisabledActivity]], BasicHandler[AgenticUserDisabledActivity]]: ... + + def on_agentic_user_disabled( + self, handler: Optional[BasicHandler[AgenticUserDisabledActivity]] = None + ) -> BasicHandlerUnion[AgenticUserDisabledActivity]: + """Register a agentic_user_disabled activity handler.""" + + def decorator(func: BasicHandler[AgenticUserDisabledActivity]) -> BasicHandler[AgenticUserDisabledActivity]: + validate_handler_type( + func, AgenticUserDisabledActivity, "on_agentic_user_disabled", "AgenticUserDisabledActivity" + ) + config = ACTIVITY_ROUTES["agentic_user_disabled"] + self.router.add_handler(config.selector, func) + return func + + if handler is not None: + return decorator(handler) + return decorator + + @overload + def on_agentic_user_deleted( + self, handler: BasicHandler[AgenticUserDeletedActivity] + ) -> BasicHandler[AgenticUserDeletedActivity]: ... + + @overload + def on_agentic_user_deleted( + self, + ) -> Callable[[BasicHandler[AgenticUserDeletedActivity]], BasicHandler[AgenticUserDeletedActivity]]: ... + + def on_agentic_user_deleted( + self, handler: Optional[BasicHandler[AgenticUserDeletedActivity]] = None + ) -> BasicHandlerUnion[AgenticUserDeletedActivity]: + """Register a agentic_user_deleted activity handler.""" + + def decorator(func: BasicHandler[AgenticUserDeletedActivity]) -> BasicHandler[AgenticUserDeletedActivity]: + validate_handler_type( + func, AgenticUserDeletedActivity, "on_agentic_user_deleted", "AgenticUserDeletedActivity" + ) + config = ACTIVITY_ROUTES["agentic_user_deleted"] + self.router.add_handler(config.selector, func) + return func + + if handler is not None: + return decorator(handler) + return decorator + + @overload + def on_agentic_user_undeleted( + self, handler: BasicHandler[AgenticUserUndeletedActivity] + ) -> BasicHandler[AgenticUserUndeletedActivity]: ... + + @overload + def on_agentic_user_undeleted( + self, + ) -> Callable[[BasicHandler[AgenticUserUndeletedActivity]], BasicHandler[AgenticUserUndeletedActivity]]: ... + + def on_agentic_user_undeleted( + self, handler: Optional[BasicHandler[AgenticUserUndeletedActivity]] = None + ) -> BasicHandlerUnion[AgenticUserUndeletedActivity]: + """Register a agentic_user_undeleted activity handler.""" + + def decorator(func: BasicHandler[AgenticUserUndeletedActivity]) -> BasicHandler[AgenticUserUndeletedActivity]: + validate_handler_type( + func, AgenticUserUndeletedActivity, "on_agentic_user_undeleted", "AgenticUserUndeletedActivity" + ) + config = ACTIVITY_ROUTES["agentic_user_undeleted"] + self.router.add_handler(config.selector, func) + return func + + if handler is not None: + return decorator(handler) + return decorator + + @overload + def on_agentic_user_workload_onboarding_updated( + self, handler: BasicHandler[AgenticUserWorkloadOnboardingUpdatedActivity] + ) -> BasicHandler[AgenticUserWorkloadOnboardingUpdatedActivity]: ... + + @overload + def on_agentic_user_workload_onboarding_updated( + self, + ) -> Callable[ + [BasicHandler[AgenticUserWorkloadOnboardingUpdatedActivity]], + BasicHandler[AgenticUserWorkloadOnboardingUpdatedActivity], + ]: ... + + def on_agentic_user_workload_onboarding_updated( + self, handler: Optional[BasicHandler[AgenticUserWorkloadOnboardingUpdatedActivity]] = None + ) -> BasicHandlerUnion[AgenticUserWorkloadOnboardingUpdatedActivity]: + """Register a agentic_user_workload_onboarding_updated activity handler.""" + + def decorator( + func: BasicHandler[AgenticUserWorkloadOnboardingUpdatedActivity], + ) -> BasicHandler[AgenticUserWorkloadOnboardingUpdatedActivity]: + validate_handler_type( + func, + AgenticUserWorkloadOnboardingUpdatedActivity, + "on_agentic_user_workload_onboarding_updated", + "AgenticUserWorkloadOnboardingUpdatedActivity", + ) + config = ACTIVITY_ROUTES["agentic_user_workload_onboarding_updated"] + self.router.add_handler(config.selector, func) + return func + + if handler is not None: + return decorator(handler) + return decorator + @overload def on_config_open( self, handler: InvokeHandler[ConfigFetchInvokeActivity, ConfigInvokeResponse] @@ -1385,8 +1666,7 @@ def on_suggested_action_submit( def on_suggested_action_submit( self, ) -> Callable[ - [VoidInvokeHandler[SuggestedActionSubmitInvokeActivity]], - VoidInvokeHandler[SuggestedActionSubmitInvokeActivity], + [VoidInvokeHandler[SuggestedActionSubmitInvokeActivity]], VoidInvokeHandler[SuggestedActionSubmitInvokeActivity] ]: ... def on_suggested_action_submit( diff --git a/packages/apps/tests/test_agent_lifecycle_routes.py b/packages/apps/tests/test_agent_lifecycle_routes.py new file mode 100644 index 000000000..f21cc1fa2 --- /dev/null +++ b/packages/apps/tests/test_agent_lifecycle_routes.py @@ -0,0 +1,86 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import pytest +from microsoft_teams.api import Account, ConversationAccount +from microsoft_teams.api.activities.event.agent_lifecycle import ( + AgenticUserEnabledActivity, + AgenticUserEnabledValue, + AgenticUserIdentityCreatedActivity, + AgenticUserIdentityCreatedValue, + AgenticUserManagerUpdatedActivity, + AgenticUserManagerUpdatedValue, + AgentLifecycleManagerRef, +) +from microsoft_teams.apps.routing.activity_route_configs import ACTIVITY_ROUTES + + +def _identity_created() -> AgenticUserIdentityCreatedActivity: + return AgenticUserIdentityCreatedActivity( + id="lifecycle-1", + channel_id="agents", + from_=Account(id="system", name="System"), + conversation=ConversationAccount(id="conversation-1"), + recipient=Account(id="agentic-user-1"), + value=AgenticUserIdentityCreatedValue(agentic_user_id="agentic-user-1"), + ) + + +def _enabled() -> AgenticUserEnabledActivity: + return AgenticUserEnabledActivity( + id="lifecycle-2", + channel_id="agents", + from_=Account(id="system", name="System"), + conversation=ConversationAccount(id="conversation-1"), + recipient=Account(id="agentic-user-1"), + value=AgenticUserEnabledValue(agentic_user_id="agentic-user-1", version=6), + ) + + +def _manager_updated() -> AgenticUserManagerUpdatedActivity: + return AgenticUserManagerUpdatedActivity( + id="lifecycle-3", + channel_id="agents", + from_=Account(id="system", name="System"), + conversation=ConversationAccount(id="conversation-1"), + recipient=Account(id="agentic-user-1"), + value=AgenticUserManagerUpdatedValue( + agentic_user_id="agentic-user-1", manager=AgentLifecycleManagerRef(manager_id="manager-1") + ), + ) + + +def test_general_agent_lifecycle_route_matches_every_variant() -> None: + selector = ACTIVITY_ROUTES["agent_lifecycle"].selector + + assert selector(_identity_created()) + assert selector(_enabled()) + assert selector(_manager_updated()) + + +@pytest.mark.parametrize( + "route_key,activity_factory", + [ + ("agentic_user_identity_created", _identity_created), + ("agentic_user_enabled", _enabled), + ("agentic_user_manager_updated", _manager_updated), + ], +) +def test_variant_route_matches_only_its_own_variant(route_key, activity_factory) -> None: + activity = activity_factory() + + assert ACTIVITY_ROUTES[route_key].selector(activity) + + other_keys = [ + key + for key in ( + "agentic_user_identity_created", + "agentic_user_enabled", + "agentic_user_manager_updated", + ) + if key != route_key + ] + for other in other_keys: + assert not ACTIVITY_ROUTES[other].selector(activity) diff --git a/uv.lock b/uv.lock index 061ce1f51..6594e1637 100644 --- a/uv.lock +++ b/uv.lock @@ -15,6 +15,7 @@ members = [ "cards", "dialogs", "echo", + "formatted-messaging", "graph", "http-adapters", "mcp-server", @@ -983,6 +984,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, ] +[[package]] +name = "formatted-messaging" +version = "0.1.0" +source = { virtual = "examples/formatted-messaging" } +dependencies = [ + { name = "dotenv" }, + { name = "microsoft-teams-api" }, + { name = "microsoft-teams-apps" }, +] + +[package.metadata] +requires-dist = [ + { name = "dotenv", specifier = ">=0.9.9" }, + { name = "microsoft-teams-api", editable = "packages/api" }, + { name = "microsoft-teams-apps", editable = "packages/apps" }, +] + [[package]] name = "frozenlist" version = "1.8.0"