- name
- agents-sdk-dotnet-activityhandler-migration
- description
- Use when migrating a Microsoft 365 Agents SDK agent that uses ActivityHandler or TeamsActivityHandler to AgentApplication. This is ONLY for DotNet projects that uses `Microsoft.Agents.*` packages. Triggered by Agents SDK bots that subclass ActivityHandler or TeamsActivityHandler that need to be modernized to AgentApplication routing.
# Agents SDK ActivityHandler → AgentApplication Migration (.NET)
## Overview
Upgrades a bot from the ActivityHandler compat layer to the modern `AgentApplication` routing pattern. This is ONLY for DotNet projects that already use `Microsoft.Agents.*` packages. If the project is still using `Microsoft.Bot.*` packages the `bf-to-agents-sdk-dotnet-migration` skill should be used first.
**Two entry points:**
- **Standalone:** The bot already uses `Microsoft.Agents.*` packages, namespaces, and appsettings — focus is on the bot class, Program.cs, and endpoint mapping.
- **Second step after `bf-to-agents-sdk-dotnet-migration`:** Packages, namespaces, and appsettings were converted in that step — this skill handles the AgentApplication conversion.
---
## What Does NOT Need to Change
Verify each of these is already correct — do not modify unless broken:
| Item | Expected State |
|------|---------------|
| Namespaces in bot class | Already `Microsoft.Agents.*` — only remove compat namespaces |
| `appsettings.json` | Already has `Connections` + `TokenValidation` sections |
| `builder.Services.AddAgentAspNetAuthentication(...)` | Already present — keep |
| `AspNetExtensions.cs` (if present) | Sample-provided auth helper — keep as-is |
| Custom DI in Program.cs | Preserve dialogs, adapters, custom services — **but remove `ConversationState` and `UserState`** (see Step 2) |
> **⚠️ If beta packages are required:** Tell the user explicitly: *"This migration uses pre-release (`-beta`) Agents SDK packages, which are nightly builds and not suitable for production. Upgrade to a stable release before deploying."*
---
## Handler Mapping: ActivityHandler → AgentApplication
| ActivityHandler Override | AgentApplication Registration |
|--------------------------|-------------------------------|
| `OnMessageActivityAsync(ITurnContext<IMessageActivity>, CT)` | `OnActivity(ActivityTypes.Message, handler, rank: RouteRank.Last)` |
| `OnMembersAddedAsync(IList<ChannelAccount>, ITurnContext<IConversationUpdateActivity>, CT)` | `OnConversationUpdate(ConversationUpdateEvents.MembersAdded, handler)` |
| `OnMembersRemovedAsync(...)` | `OnConversationUpdate(ConversationUpdateEvents.MembersRemoved, handler)` |
| `OnMessageUpdateActivityAsync(...)` | `OnActivity(ActivityTypes.MessageUpdate, handler)` |
| `OnMessageDeleteActivityAsync(...)` | `OnActivity(ActivityTypes.MessageDelete, handler)` |
| `OnReactionsAddedAsync(IList<MessageReaction>, ...)` | `OnMessageReactionsAdded(handler)` |
| `OnReactionsRemovedAsync(IList<MessageReaction>, ...)` | `OnMessageReactionsRemoved(handler)` |
| `OnEventAsync(ITurnContext<IEventActivity>, CT)` | `OnEvent(eventName, handler)` or `OnActivity(ActivityTypes.Event, handler)` |
| `OnInvokeActivityAsync(...)` with `adaptiveCard/action` + verb switch | `AdaptiveCards.OnActionExecute(verb, handler)` — one registration per verb (preferred) |
| `OnInvokeActivityAsync(...)` with `application/search` | `AdaptiveCards.OnSearch(dataset, handler)` — one registration per `choices.data.dataset` value |
| `OnInvokeActivityAsync(...)` (other invoke types) | `OnActivity(ActivityTypes.Invoke, handler)` |
| `OnTurnAsync(...)` override (cross-cutting) | `OnBeforeTurn(handler)` / `OnAfterTurn(handler)` |
| `OnInstallationUpdateActivityAsync(...)` | `OnActivity(ActivityTypes.InstallationUpdate, handler)` |
| `OnEndOfConversationActivityAsync(...)` | `OnActivity(ActivityTypes.EndOfConversation, handler)` |
**Key difference:** Handler signature changes from `(ITurnContext<T>, CT)` to `(ITurnContext, ITurnState, CT)`.
`membersAdded`, `membersRemoved`, `messageReactions` are no longer separate parameters — access via `turnContext.Activity.*`.
### `adaptiveCard/action` invokes → `AdaptiveCards.OnActionExecute`
If the bot overrides `OnInvokeActivityAsync` and switches on a verb from `adaptiveCard/action` activities, replace it with per-verb `AdaptiveCards.OnActionExecute` registrations. `AdaptiveCards` is a property on `AgentApplication` (no `RegisterExtension` needed):
```csharp
// Before: one OnInvokeActivityAsync with switch(verb)
protected override async Task<InvokeResponse> OnInvokeActivityAsync(ITurnContext<IInvokeActivity> ctx, CancellationToken ct)
{
if (ctx.Activity.Name == "adaptiveCard/action")
{
var wrapper = JsonSerializer.Deserialize<AdaptiveCardInvokeValue>(ctx.Activity.Value.ToString());
switch (wrapper.Action.Verb)
{
case "approve": return CreateInvokeResponse(GetApproveCard(wrapper.Action.Data));
case "reject": return CreateInvokeResponse(GetRejectCard(wrapper.Action.Data));
}
}
return null;
}
// After: one registration per verb — framework filters and extracts action.data
public MyBot(AgentApplicationOptions options) : base(options)
{
AdaptiveCards.OnActionExecute("approve", OnApproveAsync);
AdaptiveCards.OnActionExecute("reject", OnRejectedAsync);
}
// Handler receives action.data directly — NOT the full AdaptiveCardInvokeValue
// Return AdaptiveCardInvokeResponse directly — no CreateInvokeResponse() wrapper
private Task<AdaptiveCardInvokeResponse> OnApproveAsync(
ITurnContext turnContext, ITurnState turnState, object data, CancellationToken ct)
{
var actionData = ProtocolJsonSerializer.ToObject<MyDataModel>(data); // data == action.data
return Task.FromResult(new AdaptiveCardInvokeResponse
{
StatusCode = 200,
Type = "application/vnd.microsoft.card.adaptive",
Value = BuildCard(actionData)
});
}
```
**Key points:**
- `data` parameter = `invokeValue.Action.Data` — deserialize as your data model, not as `AdaptiveCardInvokeValue`
- Return `AdaptiveCardInvokeResponse` directly — the framework wraps it in an `InvokeResponse` automatically
- `CreateInvokeResponse()` is a compat-layer helper that does not exist on `AgentApplication`
- Namespace: `using Microsoft.Agents.Builder.App.AdaptiveCards;`
---
## Step 1-a: Convert Bot Class to AgentApplication (not using Dialogs)
- Remove `using Microsoft.Agents.Builder.Compat;` (ActivityHandler namespace)
- Remove `using Microsoft.Agents.Extensions.Teams.Compat;` (TeamsActivityHandler namespace)
- Keep all other `using Microsoft.Agents.*` statements — they are already correct
```csharp
// Before: ActivityHandler compat layer
using Microsoft.Agents.Builder.Compat; // REMOVE
using Microsoft.Agents.Extensions.Teams.Compat; // REMOVE if Teams
using Microsoft.Agents.Builder;
using Microsoft.Agents.Core.Models;
public class EchoBot : ActivityHandler // or TeamsActivityHandler
{
protected override async Task OnMessageActivityAsync(ITurnContext<IMessageActivity> turnContext, CancellationToken ct)
{ /* business logic */
}
}
// After: AgentApplication
using Microsoft.Agents.Builder;
using Microsoft.Agents.Builder.App;
using Microsoft.Agents.Core.Models;
public class EchoBot : AgentApplication
{
public EchoBot(AgentApplicationOptions options) : base(options)
{
OnConversationUpdate(ConversationUpdateEvents.MembersAdded, OnMembersAddedAsync);
OnActivity(ActivityTypes.Message, OnMessageAsync, rank: RouteRank.Last);
}
// Signature: (ITurnContext, ITurnState, CancellationToken)
private async Task OnMessageAsync(ITurnContext ctx, ITurnState state, CancellationToken ct)
{ /* same business logic */ }
}
```
For Teams bots (`TeamsActivityHandler`) with Teams-specific handlers (messaging extensions, task modules, etc.), see **Step 1-d** below.
## Step 1-b: Convert Bot Class to AgentApplication (using Dialogs)
- Remove `using Microsoft.Agents.Builder.Compat;` (ActivityHandler namespace)
- Remove `using Microsoft.Agents.Extensions.Teams.Compat;` (TeamsActivityHandler namespace)
- Keep all other `using Microsoft.Agents.*` statements — they are already correct
```csharp
// Before: ActivityHandler compat layer
using Microsoft.Agents.Builder.Compat; // REMOVE
using Microsoft.Agents.Builder;
using Microsoft.Agents.Builder.State;
using Microsoft.Agents.Core.Models;
public class EchoBot : ActivityHandler
{
protected override Task OnMessageActivityAsync(ITurnContext<IMessageActivity> turnContext, CancellationToken ct)
=> dialog.RunAsync(turnContext, conversationState, ct);
}
// After: AgentApplication
using Microsoft.Agents.Builder;
using Microsoft.Agents.Builder.App;
using Microsoft.Agents.Builder.State;
using Microsoft.Agents.Core.Models;
public class EchoBot : AgentApplication
{
public EchoBot(AgentApplicationOptions options, MainDialog dialog) : base(options)
{
_dialog = dialog;
OnActivity(ActivityTypes.Message, OnMessageAsync, rank: RouteRank.Last);
// signin/verifyState and signin/tokenExchange are both matched by "signin/.*"
OnActivity(
(ctx, ct) => Task.FromResult(ctx.Activity.IsType(ActivityTypes.Invoke) && ctx.Activity.Name != null && Regex.IsMatch(ctx.Activity.Name, "signin/.*")),
OnSigninInvokeStateAsync);
}
// Signature: (ITurnContext, ITurnState, CancellationToken) — NOT the ActivityHandler signature
private Task OnMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken ct)
=> _dialog.RunAsync(turnContext, turnState.Conversation, ct);
private Task OnSigninInvokeStateAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken ct)
=> _dialog.RunAsync(turnContext, turnState.Conversation, ct);
}
```
**Key differences from non-dialog migration:**
- Do **not** inject `ConversationState` into the bot — pass `turnState.Conversation` to `Dialog.RunAsync` instead
- Use `InvokeRouteBuilder` (not `OnActivity(ActivityTypes.Invoke, ...)`) to route signin invokes — the name pattern matches both `signin/verifyState` and `signin/tokenExchange`
- Remove `ConversationState` and `UserState` from DI entirely — see Step 2
> ⚠️ **Generic `DialogBot<T>` base class pattern:** Some projects use a two-level class hierarchy where a generic base class (e.g. `DialogBot<T> : ActivityHandler`) holds the dialog field, state injection, and `OnTurnAsync` state-saving override, and the concrete bot class (e.g. `MyBot : DialogBot<MainDialog>`) only adds routing. Collapse both classes into a single non-generic `AgentApplication` subclass — inject the concrete dialog type directly in the constructor. The generic type parameter is not needed. Remove `OnTurnAsync` entirely (AgentApplication manages state automatically).
---
## Step 1-c: Remove State Management Boilerplate
### Remove OnTurnBeginAsync / OnTurnEndAsync Overrides
`ActivityHandler` and `TeamsActivityHandler` bots sometimes manually load and save state in turn lifecycle overrides. Remove these entirely — `AgentApplication` handles state loading and saving automatically:
```csharp
// REMOVE these overrides completely — AgentApplication does this automatically
protected override async Task OnTurnBeginAsync(ITurnContext turnContext, CancellationToken cancellationToken = default)
{
await ConversationState.LoadAsync(turnContext, false, cancellationToken);
await UserState.LoadAsync(turnContext, false, cancellationToken);
}
protected override async Task OnTurnEndAsync(ITurnContext turnContext, CancellationToken cancellationToken = default)
{
await ConversationState.SaveChangesAsync(turnContext, false, cancellationToken);
await UserState.SaveChangesAsync(turnContext, false, cancellationToken);
}
```
Also remove any `ConversationState` or `UserState` properties/fields that were injected for this purpose.
### Migrate CloudAdapter OnTurnError to AgentApplication
If a custom `CloudAdapter` subclass injects `ConversationState` only to call `DeleteStateAsync` on error, move that logic to an `AgentApplication.OnTurnError` handler instead.
**Before — in the custom adapter:**
```csharp
public MyAdapter(
IChannelServiceClientFactory channelServiceClientFactory,
IActivityTaskQueue activityTaskQueue,
ILogger<CloudAdapter> logger,
ConversationState conversationState) // REMOVE this parameter
: base(channelServiceClientFactory, activityTaskQueue, logger: logger)
{
OnTurnError = async (turnContext, exception) =>
{
if (conversationState != null)
{
await conversationState.DeleteStateAsync(turnContext); // REMOVE
}
};
}
```
**After — remove ConversationState from the adapter:**
```csharp
public MyAdapter(
IChannelServiceClientFactory channelServiceClientFactory,
IActivityTaskQueue activityTaskQueue,
ILogger<CloudAdapter> logger)
: base(channelServiceClientFactory, activityTaskQueue, logger: logger)
{
// OnTurnError can remain for logging; state deletion moves to AgentApplication
}
```
**Add to the AgentApplication bot constructor:**
```csharp
public MyBot(AgentApplicationOptions options, ...) : base(options)
{
OnTurnError(OnAgentTurnErrorAsync);
// ... other route registrations
}
private async Task OnAgentTurnErrorAsync(ITurnContext turnContext, ITurnState turnState, Exception exception, CancellationToken cancellationToken)
{
try
{
await turnState.Conversation.DeleteStateAsync(turnContext, cancellationToken);
}
catch(Exception ex)
{
Logger.LogError(ex, "Exception deleting conversation state after a turn error.");
}
}
```
Customer-written classes that accept `ConversationState` or `UserState` as constructor parameters can remain unchanged for now. However, they will ultimately need to source their state from `ITurnState.Conversation` or `ITurnState.User` — flag this to the customer as a follow-up item.
### `application/search` invokes → `AdaptiveCards.OnSearch`
If the bot overrides `OnInvokeActivityAsync` and handles `activity.Name == "application/search"` (adaptive card typeahead/dynamic search), replace it with per-dataset `AdaptiveCards.OnSearch` registrations. The dataset name comes from `choices.data.dataset` in the card JSON. The handler returns `IList<AdaptiveCardsSearchResult>` — the framework automatically wraps results in the `application/vnd.microsoft.search.searchResponse` format and sends the invoke response.
```csharp
// Before: one OnInvokeActivityAsync checking activity.Name
protected override async Task<InvokeResponse> OnInvokeActivityAsync(ITurnContext<IInvokeActivity> turnContext, CancellationToken ct)
{
if (turnContext.Activity.Name == "application/search")
{
var searchData = JsonSerializer.Deserialize<DynamicSearchCard>(turnContext.Activity.Value.ToString());
// ... build results ...
return new InvokeResponse { Status = 200, Body = searchResponseData };
}
return null;
}
// After: per-dataset OnSearch registrations
public ActivityBot(AgentApplicationOptions options) : base(options)
{
AdaptiveCards.OnSearch("npmpackages", OnNpmSearchAsync);
AdaptiveCards.OnSearch("cities", OnCitiesSearchAsync);
}
// Handler signature: (ITurnContext, ITurnState, Query<AdaptiveCardsSearchParams>, CT) → Task<IList<AdaptiveCardsSearchResult>>
private async Task<IList<AdaptiveCardsSearchResult>> OnNpmSearchAsync(
ITurnContext turnContext, ITurnState turnState, Query<AdaptiveCardsSearchParams> query, CancellationToken ct)
{
// query.Parameters.QueryText = search text typed by user
// query.Parameters.Dataset = "npmpackages"
// ... fetch results ...
return results.Select(r => new AdaptiveCardsSearchResult(r.Title, r.Value)).ToList();
}
```
**Key differences:**
- Return `IList<AdaptiveCardsSearchResult>` directly — no `InvokeResponse` wrapper
- `AdaptiveCardsSearchResult(string title, string value)` — constructor takes title and value
- `query.Parameters.QueryText` replaces `searchData.queryText`
- `query.Parameters.Dataset` contains the dataset name (same as the registration key)
> ⚠️ **Dependent dropdown — associated inputs not in `query`:** When a card uses `"associatedInputs": "auto"`, the selected values of other inputs are sent in the `data` field of the raw invoke payload — NOT in `query.Parameters`. Access them directly: `ProtocolJsonSerializer.ToObject<MyModel>(turnContext.Activity.Value)` where `MyModel` maps the top-level `data` field.
>
> ```csharp
Ver en GitHub