diff --git a/core/core.slnx b/core/core.slnx
index 70f78d0e8..02e1661ab 100644
--- a/core/core.slnx
+++ b/core/core.slnx
@@ -16,6 +16,7 @@
+
diff --git a/core/samples/ExtAIBot/Agent.cs b/core/samples/ExtAIBot/Agent.cs
new file mode 100644
index 000000000..e5b835768
--- /dev/null
+++ b/core/samples/ExtAIBot/Agent.cs
@@ -0,0 +1,155 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Collections.Concurrent;
+using System.Text;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.AI;
+using Microsoft.Teams.Apps;
+using Microsoft.Teams.Apps.Schema;
+
+namespace ExtAIBot;
+
+// Holds the IChatClient, per-conversation history, and the MCP tool set.
+// RunAsync drives a single turn: it builds per-turn tools (local + MCP wrapped for citations),
+// streams the model response, then runs a dedicated structured-output call to
+// generate exactly 2 follow-up suggestions.
+sealed class Agent
+{
+ private readonly IChatClient _chatClient;
+ private readonly McpToolSetLifetimeService _mcpTools;
+ private readonly ILogger _logger;
+ private readonly ConcurrentDictionary> _histories = new();
+ // One lock per conversation so concurrent turns on the same conversation serialize
+ // their history mutations (List is not thread-safe).
+ private readonly ConcurrentDictionary _locks = new();
+
+ private const string SystemPrompt = """
+ You are a Teams docs assistant that can search Microsoft Learn (Teams, .NET, Microsoft Graph, Azure)
+ and explain bot concepts (streaming, Adaptive Cards, citations, feedback).
+
+ When you use information from a search tool, cite your sources inline using the "citation" value \
+ provided in each result (e.g. [1], [2]).
+ Do not add a references or sources list at the end of your response — citations are displayed separately in the UI.
+ """;
+
+ private const string FollowUpsPrompt = """
+ Produce 2 specific prompts the user might want to ask next.
+
+ Output format — read carefully:
+ Return ONLY a JSON object INSTANCE, like this:
+ {"prompt1": "How do I stream a reply?", "prompt2": "Show me an Adaptive Card example"}
+
+ Each prompt MUST:
+ - Be phrased in the first person, as the user would type.
+ - Stay under 8 words.
+
+ Pick based on the conversation:
+ - If recent turns have substantive content, drill into a concrete topic, API, or
+ concept that just came up.
+ - Otherwise (e.g. conversation just started, or the last turn is generic),
+ suggest prompts that showcase what you can help with based on the MCP tools available.
+ """;
+
+ private sealed record FollowUps(
+ [property: JsonPropertyName("prompt1")] string Prompt1,
+ [property: JsonPropertyName("prompt2")] string Prompt2);
+
+ public Agent(IChatClient chatClient, McpToolSetLifetimeService mcpTools, ILogger logger)
+ {
+ _chatClient = chatClient;
+ _mcpTools = mcpTools;
+ _logger = logger;
+ }
+
+ public async Task RunAsync(
+ string conversationId,
+ string userText,
+ TeamsStreamingWriter writer,
+ CancellationToken cancellationToken)
+ {
+ List history = _histories.GetOrAdd(
+ conversationId,
+ _ => [new ChatMessage(ChatRole.System, SystemPrompt)]);
+
+ // Serialize turns within a single conversation so concurrent submits
+ // (e.g. clarification race) don't interleave history mutations.
+ SemaphoreSlim gate = _locks.GetOrAdd(conversationId, _ => new SemaphoreSlim(1, 1));
+ await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ List pendingCards = [];
+ CitationCollector citations = new(_logger);
+ McpToolSet mcpTools = _mcpTools.Value;
+
+ ChatOptions options = new()
+ {
+ Tools =
+ [
+ LocalTools.CreateClarificationCardTool(pendingCards, _logger),
+ .. mcpTools.GetTools(citations)
+ ]
+ };
+
+ history.Add(new ChatMessage(ChatRole.User, userText));
+ await writer.SendInformativeUpdateAsync("Thinking…", cancellationToken);
+
+ StringBuilder fullText = new();
+ await foreach (ChatResponseUpdate update in
+ _chatClient.GetStreamingResponseAsync(history, options, cancellationToken))
+ {
+ if (!string.IsNullOrEmpty(update.Text))
+ {
+ await writer.AppendResponseAsync(update.Text, cancellationToken);
+ fullText.Append(update.Text);
+ }
+ }
+
+ string fullTextStr = fullText.ToString();
+ if (fullTextStr.Length > 0)
+ history.Add(new ChatMessage(ChatRole.Assistant, fullTextStr));
+
+ List followUpActions = await GenerateFollowUpsAsync(history, cancellationToken);
+
+ return new RunResult(fullTextStr, pendingCards, followUpActions, citations);
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
+
+ // Runs after the streamed reply is in history. Forces structured JSON output matching
+ // the FollowUps shape so we always get exactly 2 suggestions to display as chips.
+ private async Task> GenerateFollowUpsAsync(
+ IReadOnlyList history,
+ CancellationToken cancellationToken)
+ {
+ List messages =
+ [
+ .. history,
+ new ChatMessage(ChatRole.System, FollowUpsPrompt)
+ ];
+
+ ChatResponse response = await _chatClient.GetResponseAsync(
+ messages,
+ cancellationToken: cancellationToken);
+
+ if (!response.TryGetResult(out FollowUps? followUps) || followUps is null)
+ {
+ _logger.LogWarning("Follow-up generation did not return parseable JSON. Raw response: {Text}", response.Text);
+ return [];
+ }
+
+ return [
+ new SuggestedAction(ActionType.IMBack, followUps.Prompt1),
+ new SuggestedAction(ActionType.IMBack, followUps.Prompt2)
+ ];
+ }
+}
+
+readonly record struct RunResult(
+ string FullText,
+ IList PendingCards,
+ IList FollowUpActions,
+ CitationCollector Citations);
diff --git a/core/samples/ExtAIBot/CitationCollector.cs b/core/samples/ExtAIBot/CitationCollector.cs
new file mode 100644
index 000000000..cc5a19ef3
--- /dev/null
+++ b/core/samples/ExtAIBot/CitationCollector.cs
@@ -0,0 +1,110 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using Microsoft.Teams.Apps.Schema.Entities;
+
+namespace ExtAIBot;
+
+// Parses MCP search results as they are returned by tools and accumulates citation metadata.
+// After streaming completes, BuildEntities() returns Teams CitationEntity objects for any
+// [N] references that appear in the final response text.
+sealed class CitationCollector
+{
+ private readonly ILogger _logger;
+ private readonly Dictionary _citations = [];
+
+ public CitationCollector(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public void TryExtract(string result)
+ {
+ try
+ {
+ using JsonDocument doc = JsonDocument.Parse(result);
+ if (!TryFindResults(doc.RootElement, out JsonElement results)) return;
+
+ foreach (JsonElement item in results.EnumerateArray())
+ {
+ string? url = GetString(item, "contentUrl") ?? GetString(item, "link");
+ if (url is null || _citations.ContainsKey(url)) continue;
+
+ string snippet = GetString(item, "content") ?? GetString(item, "description") ?? "";
+ _citations[url] = new CitationEntry(
+ Position: _citations.Count + 1,
+ Url: url,
+ Title: GetString(item, "title") ?? "",
+ Snippet: snippet.Length > 160 ? snippet[..160] : snippet);
+ }
+ }
+ catch (JsonException ex)
+ {
+ _logger.LogDebug(ex, "Skipped citation extraction because the tool result was not valid JSON.");
+ }
+ catch (FormatException ex)
+ {
+ _logger.LogDebug(ex, "Skipped citation extraction because the tool result had an unexpected format.");
+ }
+ }
+
+ public IList BuildEntities(string fullText)
+ {
+ HashSet used = [];
+ foreach (Match match in Regex.Matches(fullText, @"\[(\d+)\]"))
+ {
+ if (int.TryParse(match.Groups[1].Value, out int position))
+ used.Add(position);
+ }
+
+ List claims = [.. _citations.Values
+ .Where(e => used.Contains(e.Position))
+ .Select(e => new CitationClaim
+ {
+ Position = e.Position,
+ Appearance = new CitationAppearance
+ {
+ Name = string.IsNullOrEmpty(e.Title)
+ ? $"Source {e.Position}"
+ : e.Title[..Math.Min(80, e.Title.Length)],
+ Abstract = string.IsNullOrEmpty(e.Snippet)
+ ? "No description available."
+ : e.Snippet,
+ Url = Uri.TryCreate(e.Url, UriKind.Absolute, out Uri? uri) ? uri : null
+ }.ToDocument()
+ })];
+
+ // TODO : work on Add Citations/feedback/AI label etc in builder
+ return claims.Count == 0
+ ? [new OMessageEntity { AdditionalType = ["AIGeneratedContent"] }]
+ : [new CitationEntity { AdditionalType = ["AIGeneratedContent"], Citation = claims }];
+ }
+
+ // MCP InvokeAsync returns a JsonElement of CallToolResult, not the raw server JSON.
+ // Results may be at root or nested one level deep (e.g. CallToolResult.structuredContent.results).
+ private static bool TryFindResults(JsonElement element, out JsonElement results)
+ {
+ if (element.TryGetProperty("results", out results) && results.ValueKind == JsonValueKind.Array)
+ return true;
+
+ foreach (JsonProperty prop in element.EnumerateObject())
+ {
+ if (prop.Value.ValueKind == JsonValueKind.Object &&
+ prop.Value.TryGetProperty("results", out results) &&
+ results.ValueKind == JsonValueKind.Array)
+ return true;
+ }
+
+ results = default;
+ return false;
+ }
+
+ private static string? GetString(JsonElement el, string property) =>
+ el.TryGetProperty(property, out JsonElement v) && v.ValueKind == JsonValueKind.String
+ ? v.GetString()
+ : null;
+}
+
+sealed record CitationEntry(int Position, string Url, string Title, string Snippet);
diff --git a/core/samples/ExtAIBot/ExtAIBot.csproj b/core/samples/ExtAIBot/ExtAIBot.csproj
new file mode 100644
index 000000000..243be27d0
--- /dev/null
+++ b/core/samples/ExtAIBot/ExtAIBot.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/samples/ExtAIBot/LocalTools.cs b/core/samples/ExtAIBot/LocalTools.cs
new file mode 100644
index 000000000..fd4bda729
--- /dev/null
+++ b/core/samples/ExtAIBot/LocalTools.cs
@@ -0,0 +1,56 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.ComponentModel;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.AI;
+using Microsoft.Teams.Cards;
+
+namespace ExtAIBot;
+
+// Provides local AIFunction definitions that the model can call during a turn.
+static class LocalTools
+{
+ // Returns a fresh AIFunction each turn; pendingCards is a per-turn accumulator
+ // captured by closure.
+
+ private static readonly JsonSerializerOptions SerializerOptions = new()
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ };
+ public static AIFunction CreateClarificationCardTool(IList pendingCards, ILogger logger) =>
+ AIFunctionFactory.Create(
+ ([Description("The clarification question to ask the user.")] string question,
+ [Description("2–4 candidate interpretations the user can pick between.")] string[] options) =>
+ {
+ logger.LogInformation("[tool] request_clarification(question={Question}, options=[{Options}])",
+ question, string.Join(", ", options));
+ pendingCards.Add(BuildClarificationCard(question, options));
+ return "Clarification card attached.";
+ },
+ "request_clarification",
+ "Show an Adaptive Card asking the user to clarify their request when needed. " +
+ "The user picks one option and submits; their choice arrives as the next user turn.");
+
+ private static JsonElement BuildClarificationCard(string question, string[] options)
+ {
+ AdaptiveCard card = new AdaptiveCard(
+ new TextBlock(question)
+ .WithSize(TextSize.Medium)
+ .WithWeight(TextWeight.Bolder)
+ .WithWrap(true),
+ new ChoiceSetInput([.. options.Select(o => new Choice { Title = o, Value = o })])
+ .WithId("clarificationChoice")
+ .WithIsRequired(true)
+ .WithErrorMessage("Please pick one option."))
+ .WithVersion(Microsoft.Teams.Cards.Version.Version1_6)
+ .WithActions(
+ new ExecuteAction()
+ .WithTitle("Submit")
+ .WithVerb("clarification")
+ .WithAssociatedInputs(AssociatedInputs.Auto));
+
+ return JsonSerializer.SerializeToElement(card, SerializerOptions);
+ }
+}
diff --git a/core/samples/ExtAIBot/McpTools.cs b/core/samples/ExtAIBot/McpTools.cs
new file mode 100644
index 000000000..8818dc1ea
--- /dev/null
+++ b/core/samples/ExtAIBot/McpTools.cs
@@ -0,0 +1,86 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.Extensions.AI;
+using ModelContextProtocol.Client;
+
+namespace ExtAIBot;
+
+// Owns the McpClient lifetime, lists tools at startup, and returns them wrapped
+// with citation extraction so search results populate the CitationCollector.
+sealed class McpToolSet : IAsyncDisposable
+{
+ private readonly McpClient _client;
+ private readonly IList _tools;
+ private readonly ILogger _logger;
+
+ private McpToolSet(McpClient client, IList tools, ILogger logger)
+ {
+ _client = client;
+ _tools = tools;
+ _logger = logger;
+ }
+
+ public static async Task CreateAsync(ILogger logger, CancellationToken cancellationToken = default)
+ {
+ McpClient client = await McpClient.CreateAsync(
+ new HttpClientTransport(new HttpClientTransportOptions
+ {
+ Endpoint = new Uri("https://learn.microsoft.com/api/mcp"),
+ Name = "MSLearn",
+ TransportMode = HttpTransportMode.StreamableHttp
+ }),
+ cancellationToken: cancellationToken);
+
+ IList tools =
+ await client.ListToolsAsync(cancellationToken: cancellationToken);
+
+ return new McpToolSet(client, tools, logger);
+ }
+
+ // Returns each MCP tool wrapped so its results feed into the CitationCollector.
+ public IList GetTools(CitationCollector citations) =>
+ [.. _tools.Select(t => new CitationCapturingTool(t, citations, _logger))];
+
+ public ValueTask DisposeAsync() => _client.DisposeAsync();
+}
+
+sealed class McpToolSetLifetimeService(ILogger logger) : IHostedService
+{
+ private McpToolSet? _value;
+
+ public McpToolSet Value => _value ?? throw new InvalidOperationException("MCP tool set is not initialized.");
+
+ public async Task StartAsync(CancellationToken cancellationToken)
+ {
+ _value = await McpToolSet.CreateAsync(logger, cancellationToken);
+ }
+
+ public async Task StopAsync(CancellationToken cancellationToken)
+ {
+ if (_value is null) return;
+
+ await _value.DisposeAsync();
+ _value = null;
+ }
+}
+
+// Wraps an McpClientTool, delegating all metadata to it while intercepting
+// InvokeCoreAsync to extract citation data from the raw result string.
+file sealed class CitationCapturingTool(McpClientTool inner, CitationCollector citations, ILogger logger)
+ : DelegatingAIFunction(inner)
+{
+ protected override async ValueTask InvokeCoreAsync(
+ AIFunctionArguments arguments,
+ CancellationToken cancellationToken)
+ {
+ logger.LogInformation("[tool] {Name}({Args})",
+ inner.Name,
+ string.Join(", ", arguments.Select(a => $"{a.Key}={a.Value}")));
+
+ object? result = await inner.InvokeAsync(arguments, cancellationToken);
+ if (result?.ToString() is string text)
+ citations.TryExtract(text);
+ return result;
+ }
+}
diff --git a/core/samples/ExtAIBot/Program.cs b/core/samples/ExtAIBot/Program.cs
new file mode 100644
index 000000000..4101d4ede
--- /dev/null
+++ b/core/samples/ExtAIBot/Program.cs
@@ -0,0 +1,43 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.ClientModel;
+using Azure.AI.OpenAI;
+using ExtAIBot;
+using Microsoft.Extensions.AI;
+using Microsoft.Teams.Apps;
+
+// Wires up the Teams bot application and delegates AI execution to Agent.
+// Handler registration lives in TeamsBotAppHandlers.cs.
+
+WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args);
+builder.Services.AddTeamsBotApplication();
+
+builder.Services.AddSingleton(sp =>
+{
+ IConfiguration config = sp.GetRequiredService();
+ string endpoint = config["AzureOpenAI:Endpoint"] ?? throw new InvalidOperationException("AzureOpenAI:Endpoint is required.");
+ string apiKey = config["AzureOpenAI:ApiKey"] ?? throw new InvalidOperationException("AzureOpenAI:ApiKey is required.");
+ string modelId = config["AzureOpenAI:ModelId"] ?? throw new InvalidOperationException("AzureOpenAI:ModelId is required.");
+
+ return new AzureOpenAIClient(new Uri(endpoint), new ApiKeyCredential(apiKey))
+ .GetChatClient(modelId)
+ .AsIChatClient()
+ .AsBuilder()
+ .UseFunctionInvocation()
+ .Build();
+});
+
+builder.Services.AddSingleton();
+builder.Services.AddHostedService(sp => sp.GetRequiredService());
+
+builder.Services.AddSingleton();
+
+WebApplication webApp = builder.Build();
+
+Agent agent = webApp.Services.GetRequiredService();
+ILogger handlerLogger = webApp.Services.GetRequiredService().CreateLogger("ExtAIBot.TeamsBotAppHandlers");
+
+webApp.UseTeamsBotApplication().RegisterHandlers(agent, handlerLogger);
+
+webApp.Run();
diff --git a/core/samples/ExtAIBot/README.md b/core/samples/ExtAIBot/README.md
new file mode 100644
index 000000000..075afc96c
--- /dev/null
+++ b/core/samples/ExtAIBot/README.md
@@ -0,0 +1,91 @@
+# ExtAIBot — Microsoft.Extensions.AI sample
+
+A Teams bot powered by [Microsoft.Extensions.AI](https://learn.microsoft.com/dotnet/ai/ai-extensions) and Azure OpenAI. Demonstrates streaming responses, per-conversation memory, a local clarification tool, remote MCP server tools, inline citations, follow-up suggestions, and custom feedback.
+
+## Features
+
+- **Streaming** — token-by-token replies via `TeamsStreamingWriter`
+- **Conversation memory** — each conversation keeps its own `List` so the bot remembers context across turns
+- **Local tool** — the model calls `request_clarification` when the user's request is ambiguous; the bot replies with an Adaptive Card listing 2–4 candidate interpretations
+- **MCP client** — connects to the [Microsoft Learn docs MCP server](https://learn.microsoft.com/api/mcp) at startup; its tools are passed alongside the local tool in every `ChatOptions`
+- **Inline citations** — MCP tool results are intercepted by `CitationCapturingTool` to extract source URLs; citations render as `[1]`, `[2]`, etc. in the Teams message
+- **Follow-up suggestions** — after each reply, a structured-output call produces two short follow-up prompts shown as suggested-action chips
+- **Custom feedback** — every text reply enables `FeedbackType.Custom`; clicking thumbs up/down opens a bot-rendered task module form, and submissions are handled by the typed `OnMessageSubmitFeedback` route
+
+## Prerequisites
+
+- .NET 10 SDK
+- Azure OpenAI resource with a deployed model (e.g. `gpt-4o`)
+- Teams bot registration (App ID + client secret)
+
+## Setup
+
+Fill in `appsettings.json` with your Azure OpenAI details:
+
+```json
+{
+ "AzureOpenAI": {
+ "Endpoint": "https://.openai.azure.com",
+ "ApiKey": "",
+ "ModelId": ""
+ }
+}
+```
+
+`ModelId` is the **deployment name**, not the base model name.
+
+Configure bot credentials via environment variables (or `launchSettings.json`):
+
+```
+AzureAD__TenantId=
+AzureAD__ClientId=
+AzureAD__ClientCredentials__0__SourceType=ClientSecret
+AzureAD__ClientCredentials__0__ClientSecret=
+```
+
+Then point your bot's messaging endpoint at this service (e.g. using [Dev Tunnels](https://learn.microsoft.com/azure/developer/dev-tunnels/overview) for local development).
+
+## Running
+
+```bash
+cd samples/ExtAIBot
+dotnet run
+```
+
+The bot initializes the MS Learn MCP tool set at startup before accepting messages. If the MCP server is unreachable the app will fail to start.
+
+## Example interactions
+
+- `Tell me about streaming` — ambiguous request: the model calls `request_clarification` and the bot replies with a clarification card.
+- `How do I stream in teams.net?` — model calls an MS Learn search tool, replies with docs-grounded answer and inline citations, plus two follow-up chips
+- `How do I list users with Microsoft Graph?` — same MCP search path, but lands on Graph documentation; reply cites the relevant `/users` endpoint docs and shows a code snippet
+
+### Clarification flow
+
+When the user's message is ambiguous, the model calls `request_clarification` with a question and 2–4 options. `LocalTools` builds an Adaptive Card with an `Action.Execute` whose `verb` is `"clarification"`. The bot finalizes the reply as an attachment-only message (no text, no feedback loop) so the card stands alone.
+
+When the user picks an option, Teams sends an `adaptiveCard/action` invoke. `OnAdaptiveCardAction` reads `clarificationChoice` from the action's data and feeds it back through the agent as the next user turn.
+
+### Feedback flow
+
+Every text reply is finalized with `FeedbackType.Custom`, which renders thumbs up/down on the bot bubble. Clicking either button sends a `message/fetchTask` invoke; `OnMessageFetchTask` returns a task module containing a follow-up text form built with `Microsoft.Teams.Cards`. On submit, the typed `OnMessageSubmitFeedback` route fires with `context.Activity.Value` already deserialized to `MessageSubmitFeedbackValue { Reaction, Feedback }`. (`Feedback` is the form payload as a JSON-encoded string — Teams wraps the inputs the bot defined in its task module.)
+
+
+### How MCP tools are wired in
+
+At startup, `McpToolSet.CreateAsync` connects to the MS Learn MCP server using the Streamable HTTP transport and lists its tools. Each `McpClientTool` is an `AIFunction` that holds a reference to the client and calls the server when invoked.
+
+Each tool is wrapped in a `CitationCapturingTool` (a `DelegatingAIFunction`) that intercepts the result to extract citation URLs before passing it back to the model. These are spread into `ChatOptions.Tools` alongside the local clarification tool:
+
+```csharp
+ChatOptions options = new()
+{
+ Tools =
+ [
+ LocalTools.CreateClarificationCardTool(pendingCards, _logger),
+ .. _mcpTools.GetTools(citations)
+ ]
+};
+```
+
+`UseFunctionInvocation()` then handles all tool calls — local or remote — transparently during streaming.
diff --git a/core/samples/ExtAIBot/TeamsBotAppHandlers.cs b/core/samples/ExtAIBot/TeamsBotAppHandlers.cs
new file mode 100644
index 000000000..3d95d6cf4
--- /dev/null
+++ b/core/samples/ExtAIBot/TeamsBotAppHandlers.cs
@@ -0,0 +1,117 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.Teams.Apps;
+using Microsoft.Teams.Apps.Handlers;
+using Microsoft.Teams.Apps.Handlers.TaskModules;
+using Microsoft.Teams.Apps.Schema;
+using Microsoft.Teams.Apps.Schema.Entities;
+using Microsoft.Teams.Cards;
+
+namespace ExtAIBot;
+
+// Bot activity handlers: incoming messages, clarification-card submits, and the
+// custom-feedback fetch/submit pair. Each handler ultimately funnels a user-supplied
+// string back through the Agent.
+internal static class TeamsBotAppHandlers
+{
+ public static TeamsBotApplication RegisterHandlers(this TeamsBotApplication teamsApp, Agent agent, ILogger logger)
+ {
+ // Message handler.
+ teamsApp.OnMessage(async (context, cancellationToken) =>
+ {
+ string userText = context.Activity.TextWithoutMentions ?? "";
+ await RespondAsync(agent, context, userText, cancellationToken);
+ });
+
+ // Clarification: adaptive card action.
+ // Triggered when the user submits the clarification card (Action.Execute, verb "clarification").
+ teamsApp.OnAdaptiveCardAction(async (context, cancellationToken) =>
+ {
+ if (context.Activity.Value?.Action?.Verb == "clarification")
+ {
+ string choice = context.Activity.Value.Action.Data?["clarificationChoice"]?.ToString() ?? "";
+ await RespondAsync(agent, context, choice, cancellationToken);
+ }
+ return InvokeResponse.Ok();
+ });
+
+ // Feedback: message fetch task.
+ // Triggered when the user clicks thumbs up or thumbs down on a bot reply.
+ teamsApp.OnMessageFetchTask((context, cancellationToken) =>
+ {
+ string? reaction = context.Activity.Value?.Data?.ActionValue?.Reaction;
+
+ return Task.FromResult(TaskModuleResponse.CreateBuilder()
+ .WithType(TaskModuleResponseType.Continue)
+ .WithTitle("Feedback")
+ .WithHeight(TaskModuleSize.Small)
+ .WithWidth(TaskModuleSize.Small)
+ .WithCard(BuildFeedbackCard(reaction))
+ .Build());
+ });
+
+ // Feedback: message submit action.
+ teamsApp.OnMessageSubmitFeedback((context, cancellationToken) =>
+ {
+ MessageSubmitFeedbackValue? feedback = context.Activity.Value;
+ logger.LogInformation("Feedback received — reaction: {Reaction}, feedback: {Feedback}",
+ feedback?.Reaction, feedback?.Feedback);
+ return Task.FromResult(InvokeResponse.Ok());
+ });
+
+ return teamsApp;
+ }
+
+ // Runs the agent and streams a response back. Shared between the incoming-message
+ // handler and the clarification-card submit handler — both flows ultimately just
+ // feed a user-supplied string into the agent.
+ private static async Task RespondAsync(Agent agent, Context context, string userText, CancellationToken cancellationToken)
+ where TActivity : TeamsActivity
+ {
+ _ = context.Activity.Conversation?.Id
+ ?? throw new InvalidOperationException("Missing conversation ID.");
+
+ TeamsStreamingWriter writer = TeamsStreamingWriter.CreateFromContext(context);
+ RunResult result = await agent.RunAsync(context.Activity.Conversation!.Id, userText, writer, cancellationToken);
+
+ IList entities = result.Citations.BuildEntities(result.FullText);
+
+ MessageActivity final = new();
+
+ if (result.PendingCards.Count > 0)
+ {
+ // Card-only reply (e.g. clarification). No text and no feedback — the card IS the question.
+ final.Text = "";
+ final.AddAttachment([.. result.PendingCards.Select(c =>
+ TeamsAttachment.CreateBuilder().WithAdaptiveCard(c).Build())]);
+ }
+ else
+ {
+ final.AddFeedback(FeedbackType.Custom);
+ }
+
+ foreach (Entity entity in entities) final.AddEntity(entity);
+
+ if (result.FollowUpActions.Count > 0)
+ final.WithSuggestedActions(new SuggestedActions().AddActions([.. result.FollowUpActions]));
+
+ await writer.FinalizeResponseAsync(final, cancellationToken);
+ }
+
+ private static TeamsAttachment BuildFeedbackCard(string? reaction)
+ {
+ return TeamsAttachment.CreateBuilder()
+ .WithAdaptiveCard(new AdaptiveCard(
+ new TextBlock(reaction is null
+ ? "Tell us more about your experience:"
+ : $"You clicked {reaction}. Tell us more:")
+ .WithWrap(true),
+ new TextInput()
+ .WithId("feedbackText")
+ .WithPlaceholder("Enter your feedback here...")
+ .WithIsMultiline(true))
+ .WithActions(new SubmitAction().WithTitle("Submit")))
+ .Build();
+ }
+}
diff --git a/core/samples/ExtAIBot/appsettings.json b/core/samples/ExtAIBot/appsettings.json
new file mode 100644
index 000000000..057db1509
--- /dev/null
+++ b/core/samples/ExtAIBot/appsettings.json
@@ -0,0 +1,15 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Warning",
+ "Microsoft.Teams": "Warning",
+ "ExtAIBot": "Information"
+ }
+ },
+ "AllowedHosts": "*",
+ "AzureOpenAI": {
+ "Endpoint": "",
+ "ApiKey": "",
+ "ModelId": ""
+ }
+}
diff --git a/core/samples/StreamingBot/Program.cs b/core/samples/StreamingBot/Program.cs
index 6d467c56f..dd60c6ac7 100644
--- a/core/samples/StreamingBot/Program.cs
+++ b/core/samples/StreamingBot/Program.cs
@@ -77,11 +77,11 @@ [new ChatMessage(ChatRole.User, userText)],
})
.Build();
- await writer.FinalizeResponseAsync(
- attachments: [card],
- entities: [citation],
- feedbackEnabled: true,
- cancellationToken: cancellationToken);
+ MessageActivity final = new MessageActivity().AddAttachment(card);
+ final.AddEntity(citation);
+ final.AddFeedback(FeedbackType.Default);
+
+ await writer.FinalizeResponseAsync(final, cancellationToken);
});
teamsApp.OnMessageSubmitAction(async (context, cancellationToken) =>
diff --git a/core/src/Microsoft.Teams.Apps/Handlers/InvokeHandler.Activity.cs b/core/src/Microsoft.Teams.Apps/Handlers/InvokeHandler.Activity.cs
index ff0cfb510..46c0c6bfd 100644
--- a/core/src/Microsoft.Teams.Apps/Handlers/InvokeHandler.Activity.cs
+++ b/core/src/Microsoft.Teams.Apps/Handlers/InvokeHandler.Activity.cs
@@ -200,6 +200,11 @@ public static class InvokeNames
///
public const string MessageExtensionSubmitAction = "composeExtension/submitAction";
+ ///
+ /// Message fetch task invoke name. Sent when the user clicks a feedback button on an AI-generated message.
+ ///
+ public const string MessageFetchTask = "message/fetchTask";
+
///
/// Message submit action invoke name.
///
diff --git a/core/src/Microsoft.Teams.Apps/Handlers/MessageFetchTaskHandler.Value.cs b/core/src/Microsoft.Teams.Apps/Handlers/MessageFetchTaskHandler.Value.cs
new file mode 100644
index 000000000..e07c511d6
--- /dev/null
+++ b/core/src/Microsoft.Teams.Apps/Handlers/MessageFetchTaskHandler.Value.cs
@@ -0,0 +1,50 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Teams.Apps.Handlers;
+
+///
+/// Defines the structure that arrives in the Activity.Value for an Invoke activity with
+/// Name of 'message/fetchTask'. Sent when the user clicks a feedback button (like/dislike)
+/// on an AI-generated message.
+///
+public class MessageFetchTaskInvokeValue
+{
+ ///
+ /// The data payload containing action name and value.
+ ///
+ [JsonPropertyName("data")]
+ public MessageFetchTaskData? Data { get; set; }
+}
+
+///
+/// The data payload nested inside the fetch task value.
+///
+public class MessageFetchTaskData
+{
+ ///
+ /// The name of the action.
+ ///
+ [JsonPropertyName("actionName")]
+ public string? ActionName { get; set; }
+
+ ///
+ /// Contains the user's reaction.
+ ///
+ [JsonPropertyName("actionValue")]
+ public MessageFetchTaskActionValue? ActionValue { get; set; }
+}
+
+///
+/// The nested action value containing the user's reaction.
+///
+public class MessageFetchTaskActionValue
+{
+ ///
+ /// The feedback button the user clicked. Either "like" or "dislike".
+ ///
+ [JsonPropertyName("reaction")]
+ public string? Reaction { get; set; }
+}
diff --git a/core/src/Microsoft.Teams.Apps/Handlers/MessageFetchTaskHandler.cs b/core/src/Microsoft.Teams.Apps/Handlers/MessageFetchTaskHandler.cs
new file mode 100644
index 000000000..8a400fa88
--- /dev/null
+++ b/core/src/Microsoft.Teams.Apps/Handlers/MessageFetchTaskHandler.cs
@@ -0,0 +1,48 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.Teams.Apps.Handlers.TaskModules;
+using Microsoft.Teams.Apps.Routing;
+using Microsoft.Teams.Apps.Schema;
+
+namespace Microsoft.Teams.Apps.Handlers;
+
+///
+/// Delegate for handling message fetch task invoke activities.
+///
+/// The context for the invoke activity, providing access to the activity data and bot application.
+/// A cancellation token that can be used to cancel the operation.
+/// A task that represents the asynchronous operation. The task result contains the invoke response.
+public delegate Task> MessageFetchTaskHandler(Context> context, CancellationToken cancellationToken = default);
+
+///
+/// Extension methods for registering message fetch task invoke handlers.
+///
+public static class MessageFetchTaskExtensions
+{
+ ///
+ /// Registers a handler for message fetch task invoke activities (message/fetchTask).
+ /// Triggered when the user clicks a feedback button on an AI-generated message.
+ /// Cannot be combined with .
+ ///
+ /// The Teams bot application.
+ /// The handler to register.
+ /// The updated Teams bot application.
+ public static TeamsBotApplication OnMessageFetchTask(this TeamsBotApplication app, MessageFetchTaskHandler handler)
+ {
+ ArgumentNullException.ThrowIfNull(app, nameof(app));
+ app.Router.Register(new Route
+ {
+ Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.MessageFetchTask),
+ Selector = activity => activity.Name == InvokeNames.MessageFetchTask,
+ HandlerWithReturn = async (ctx, cancellationToken) =>
+ {
+ InvokeActivity typedActivity = new(ctx.Activity);
+ Context> typedContext = new(ctx.TeamsBotApplication, typedActivity);
+ return await handler(typedContext, cancellationToken).ConfigureAwait(false);
+ }
+ });
+
+ return app;
+ }
+}
diff --git a/core/src/Microsoft.Teams.Apps/Handlers/MessageSubmitActionHandler.Value.cs b/core/src/Microsoft.Teams.Apps/Handlers/MessageSubmitActionHandler.Value.cs
index 0238ed2e7..c3485ad56 100644
--- a/core/src/Microsoft.Teams.Apps/Handlers/MessageSubmitActionHandler.Value.cs
+++ b/core/src/Microsoft.Teams.Apps/Handlers/MessageSubmitActionHandler.Value.cs
@@ -24,3 +24,25 @@ public class SubmitActionValue
[JsonPropertyName("actionValue")]
public JsonNode? ActionValue { get; set; }
}
+
+///
+/// Strongly-typed shape of when
+/// is "feedback" — i.e. when the user
+/// submits a custom feedback form. Mirrors the payload Teams sends after the user
+/// clicks Submit on the bot's feedback task module.
+///
+public class MessageSubmitFeedbackValue
+{
+ ///
+ /// The reaction the user clicked. Typically "like" or "dislike" .
+ ///
+ [JsonPropertyName("reaction")]
+ public string? Reaction { get; set; }
+
+ ///
+ /// The user's response, as a JSON-encoded string containing the form input values
+ /// (e.g. {"feedbackText":"..."} ). Parse with JsonDocument.Parse to read individual fields.
+ ///
+ [JsonPropertyName("feedback")]
+ public string? Feedback { get; set; }
+}
diff --git a/core/src/Microsoft.Teams.Apps/Handlers/MessageSubmitActionHandler.cs b/core/src/Microsoft.Teams.Apps/Handlers/MessageSubmitActionHandler.cs
index 625e3f01c..abfc73a31 100644
--- a/core/src/Microsoft.Teams.Apps/Handlers/MessageSubmitActionHandler.cs
+++ b/core/src/Microsoft.Teams.Apps/Handlers/MessageSubmitActionHandler.cs
@@ -14,6 +14,15 @@ namespace Microsoft.Teams.Apps.Handlers;
/// A task that represents the asynchronous operation. The task result contains the invoke response.
public delegate Task MessageSubmitActionHandler(Context> context, CancellationToken cancellationToken = default);
+///
+/// Delegate for handling message/submitAction invokes whose actionName is "feedback" .
+/// The activity's Value is the typed inner .
+///
+/// The context for the invoke activity, providing access to the typed feedback value and bot application.
+/// A cancellation token that can be used to cancel the operation.
+/// A task that represents the asynchronous operation. The task result contains the invoke response.
+public delegate Task MessageSubmitFeedbackHandler(Context> context, CancellationToken cancellationToken = default);
+
///
/// Extension methods for registering message submit action invoke handlers.
///
@@ -43,4 +52,35 @@ public static TeamsBotApplication OnMessageSubmitAction(this TeamsBotApplication
return app;
}
+
+ ///
+ /// Registers a handler for message/submitAction invokes where actionName == "feedback" .
+ /// The handler receives the inner actionValue typed as .
+ /// Register this before any general so the feedback-specific route
+ /// wins on first-match dispatch.
+ ///
+ /// The Teams bot application.
+ /// The handler to register.
+ /// The updated Teams bot application.
+ public static TeamsBotApplication OnMessageSubmitFeedback(this TeamsBotApplication app, MessageSubmitFeedbackHandler handler)
+ {
+ ArgumentNullException.ThrowIfNull(app, nameof(app));
+ app.Router.Register(new Route
+ {
+ Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.MessageSubmitAction, "feedback"),
+ Selector = activity =>
+ activity.Name == InvokeNames.MessageSubmitAction
+ && activity.Value?["actionName"]?.GetValue() == "feedback",
+ HandlerWithReturn = async (ctx, cancellationToken) =>
+ {
+ InvokeActivity typedActivity = new(ctx.Activity);
+ ((InvokeActivity)typedActivity).Value = ctx.Activity.Value?["actionValue"];
+
+ Context> typedContext = new(ctx.TeamsBotApplication, typedActivity);
+ return await handler(typedContext, cancellationToken).ConfigureAwait(false);
+ }
+ });
+
+ return app;
+ }
}
diff --git a/core/src/Microsoft.Teams.Apps/Schema/Entities/CitationEntity.cs b/core/src/Microsoft.Teams.Apps/Schema/Entities/CitationEntity.cs
index 731dd1d46..a9dd4654f 100644
--- a/core/src/Microsoft.Teams.Apps/Schema/Entities/CitationEntity.cs
+++ b/core/src/Microsoft.Teams.Apps/Schema/Entities/CitationEntity.cs
@@ -75,6 +75,25 @@ public static TeamsActivity AddFeedback(this TeamsActivity activity, bool value
return activity;
}
+ ///
+ /// Enables the feedback loop with an explicit mode.
+ /// Use for Teams' built-in thumbs up/down UI,
+ /// or to trigger a message/fetchTask
+ /// invoke so the bot can return its own task module dialog.
+ ///
+ /// The activity to enable feedback on. Cannot be null.
+ /// The feedback loop type. See for known values.
+ /// The activity for chaining.
+ public static TeamsActivity AddFeedback(this TeamsActivity activity, string mode)
+ {
+ ArgumentNullException.ThrowIfNull(activity);
+
+ activity.ChannelData ??= new TeamsChannelData();
+ activity.ChannelData.FeedbackLoop = new FeedbackLoop(mode);
+ activity.ChannelData.FeedbackLoopEnabled = null;
+ return activity;
+ }
+
///
/// Adds a content sensitivity label to the activity.
///
diff --git a/core/src/Microsoft.Teams.Apps/Schema/SuggestedAction.cs b/core/src/Microsoft.Teams.Apps/Schema/SuggestedAction.cs
index 60d366a9a..441886878 100644
--- a/core/src/Microsoft.Teams.Apps/Schema/SuggestedAction.cs
+++ b/core/src/Microsoft.Teams.Apps/Schema/SuggestedAction.cs
@@ -18,14 +18,16 @@ public SuggestedAction()
}
///
- /// Initializes a new instance of the class with the specified type and title.
+ /// Initializes a new instance of the class with the specified type, title, and value.
///
/// The type of action. See for common values.
/// The text description displayed on the button.
- public SuggestedAction(string type, string title)
+ /// The value sent when the button is clicked. Defaults to when not specified.
+ public SuggestedAction(string type, string title, string? value = null)
{
Type = type;
Title = title;
+ Value = value ?? title;
}
///
diff --git a/core/src/Microsoft.Teams.Apps/Schema/TeamsChannelData.cs b/core/src/Microsoft.Teams.Apps/Schema/TeamsChannelData.cs
index 2fa6446e5..1c2647a7f 100644
--- a/core/src/Microsoft.Teams.Apps/Schema/TeamsChannelData.cs
+++ b/core/src/Microsoft.Teams.Apps/Schema/TeamsChannelData.cs
@@ -132,6 +132,13 @@ public TeamsChannelData(ChannelData? cd)
{
FeedbackLoopEnabled = jeFeedback.GetBoolean();
}
+
+ if (cd.Properties.TryGetValue("feedbackLoop", out object? loopObj)
+ && loopObj is JsonElement jeLoop
+ && jeLoop.ValueKind == JsonValueKind.Object)
+ {
+ FeedbackLoop = JsonSerializer.Deserialize(jeLoop.GetRawText());
+ }
}
}
@@ -181,4 +188,51 @@ public TeamsChannelData(ChannelData? cd)
///
[JsonPropertyName("feedbackLoopEnabled")] public bool? FeedbackLoopEnabled { get; set; }
+ ///
+ /// Feedback loop configuration. When set, takes precedence over
+ /// . Set Type to
+ /// to trigger a message/fetchTask
+ /// invoke for a bot-provided task module dialog.
+ ///
+ [JsonPropertyName("feedbackLoop")] public FeedbackLoop? FeedbackLoop { get; set; }
+}
+
+///
+/// Known values for .
+///
+public static class FeedbackType
+{
+ /// Teams' built-in thumbs up/down UI.
+ public const string Default = "default";
+
+ ///
+ /// Triggers a message/fetchTask invoke so the bot can return its
+ /// own task module dialog when the user clicks thumbs up/down.
+ ///
+ public const string Custom = "custom";
+}
+
+///
+/// Configuration for a feedback loop on a message. Serializes to
+/// channelData.feedbackLoop . Must not coexist with
+/// — Teams rejects activities
+/// that set both.
+///
+public class FeedbackLoop
+{
+ ///
+ /// The feedback loop type. See for known values.
+ ///
+ [JsonPropertyName("type")] public string Type { get; set; } = FeedbackType.Default;
+
+ ///
+ /// Creates a new instance with the default type.
+ ///
+ public FeedbackLoop() { }
+
+ ///
+ /// Creates a new instance with the specified type.
+ ///
+ /// The feedback loop type. See for known values.
+ public FeedbackLoop(string type) { Type = type; }
}
diff --git a/core/src/Microsoft.Teams.Apps/TeamsStreamingWriter.cs b/core/src/Microsoft.Teams.Apps/TeamsStreamingWriter.cs
index e60530a4b..2cbc17c1b 100644
--- a/core/src/Microsoft.Teams.Apps/TeamsStreamingWriter.cs
+++ b/core/src/Microsoft.Teams.Apps/TeamsStreamingWriter.cs
@@ -27,12 +27,14 @@ namespace Microsoft.Teams.Apps;
/// await writer.FinalizeResponseAsync(); // sends accumulated " Hello, world"
///
///
-/// Entities and Attachments are only sent with the final message activity.
-/// Pass them directly to :
+/// To attach entities, attachments, suggested actions, or feedback to the final message,
+/// build a and pass it in. If its Text is null the
+/// writer fills in the accumulated streamed text.
///
-/// await writer.FinalizeResponseAsync(
-/// entities: [new CitationEntity(...)],
-/// attachments: [new TeamsAttachment(...)]);
+/// MessageActivity final = new MessageActivity().AddAttachment(card);
+/// final.AddEntity(citation);
+/// final.AddFeedback(FeedbackType.Default);
+/// await writer.FinalizeResponseAsync(final);
///
///
public sealed class TeamsStreamingWriter
@@ -124,14 +126,20 @@ ex.StatusCode is HttpStatusCode.Gone or HttpStatusCode.NoContent
}
///
- /// Sends the accumulated text as the final update (streamType = "final") and marks the stream complete.
+ /// Sends the final streaming activity and marks the stream complete.
///
- /// Optional attachments to include in the final message activity.
- /// Optional entities (e.g. citations, mentions) to include in the final message activity.
- /// Whether to enable the feedback loop (thumbs up/down) on the final message.
+ ///
+ /// The final message activity. If null , a plain is built
+ /// from the accumulated streamed text. If non-null, the caller-supplied activity is used as-is;
+ /// when its is null , the accumulated text is filled in
+ /// (pass "" explicitly to send an attachment-only reply).
+ ///
/// Cancellation token.
- /// Thrown if has already been called, or if no content has been accumulated via .
- public async Task FinalizeResponseAsync(IList? attachments = null, IList? entities = null, bool feedbackEnabled = false, CancellationToken cancellationToken = default)
+ ///
+ /// Thrown if has already been called, or if the final
+ /// activity has neither text nor attachments.
+ ///
+ public async Task FinalizeResponseAsync(MessageActivity? final = null, CancellationToken cancellationToken = default)
{
if (_finalized)
throw new InvalidOperationException("Cannot finalize after FinalizeResponseAsync has already been called.");
@@ -139,55 +147,40 @@ public async Task FinalizeResponseAsync(IList? attachments = nu
if (_cancelled)
return;
- if (_accumulated.Length == 0 && (attachments == null || attachments.Count == 0))
- throw new InvalidOperationException("Cannot finalize with no content. Call AppendResponseAsync at least once before FinalizeResponseAsync.");
+ final ??= new MessageActivity();
+ final.Text ??= _accumulated.ToString();
- _logger.LogDebug("Finalizing stream (streamId '{StreamId}', {Length} chars, {Sequences} sequences).", _streamId, _accumulated.Length, _sequence);
- await _client.SendActivityAsync(BuildActivity(_accumulated.ToString(), StreamType.Final, attachments, entities, feedbackEnabled), cancellationToken: cancellationToken).ConfigureAwait(false);
+ if (string.IsNullOrEmpty(final.Text) && (final.Attachments == null || final.Attachments.Count == 0))
+ throw new InvalidOperationException(
+ "Cannot finalize with no content. Stream text via AppendResponseAsync, or provide attachments on the final MessageActivity.");
- _finalized = true;
- _logger.LogDebug("Stream finalized (streamId '{StreamId}').", _streamId);
- }
-
- private TeamsActivity BuildActivity(string text, string streamType, IList? attachments = null, IList? entities = null, bool feedbackEnabled = false)
- {
- bool isFinal = streamType == StreamType.Final;
-
- TeamsActivityBuilder builder;
-
- if (isFinal)
- {
- StreamInfoEntity streamInfo = new() { StreamType = streamType };
- if (_streamId != null)
- streamInfo.StreamId = _streamId;
+ StreamInfoEntity streamInfo = new() { StreamType = StreamType.Final };
+ if (_streamId != null) streamInfo.StreamId = _streamId;
- builder = new TeamsActivityBuilder(new MessageActivity(text))
- .WithConversationReference(_reference)
- .AddEntity(streamInfo);
+ TeamsActivity activity = new TeamsActivityBuilder(final)
+ .WithConversationReference(_reference)
+ .AddEntity(streamInfo)
+ .Build();
- if (entities != null)
- foreach (Entity entity in entities)
- builder.AddEntity(entity);
+ _logger.LogDebug("Finalizing stream (streamId '{StreamId}', {Length} chars, {Sequences} sequences).",
+ _streamId, final.Text?.Length ?? 0, _sequence);
- if (attachments?.Count > 0)
- builder.WithAttachments(attachments);
+ await _client.SendActivityAsync(activity, cancellationToken: cancellationToken).ConfigureAwait(false);
- TeamsActivity activity = builder.Build();
- if (feedbackEnabled) activity.AddFeedback();
- return activity;
- }
- else
- {
- StreamingActivity streaming = new(text);
- streaming.StreamInfo.StreamType = streamType;
- streaming.StreamInfo.StreamSequence = _sequence;
- if (_streamId != null)
- streaming.StreamInfo.StreamId = _streamId;
-
- builder = new TeamsActivityBuilder(streaming)
- .WithConversationReference(_reference);
- }
+ _finalized = true;
+ _logger.LogDebug("Stream finalized (streamId '{StreamId}').", _streamId);
+ }
- return builder.Build();
+ private TeamsActivity BuildActivity(string text, string streamType)
+ {
+ StreamingActivity streaming = new(text);
+ streaming.StreamInfo.StreamType = streamType;
+ streaming.StreamInfo.StreamSequence = _sequence;
+ if (_streamId != null)
+ streaming.StreamInfo.StreamId = _streamId;
+
+ return new TeamsActivityBuilder(streaming)
+ .WithConversationReference(_reference)
+ .Build();
}
}
diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/TeamsStreamingWriterTests.cs b/core/test/Microsoft.Teams.Apps.UnitTests/TeamsStreamingWriterTests.cs
index 0bca48fce..f59b5673a 100644
--- a/core/test/Microsoft.Teams.Apps.UnitTests/TeamsStreamingWriterTests.cs
+++ b/core/test/Microsoft.Teams.Apps.UnitTests/TeamsStreamingWriterTests.cs
@@ -2,8 +2,10 @@
// Licensed under the MIT License.
using System.Net;
+using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Teams.Apps.Schema;
+using Microsoft.Teams.Apps.Schema.Entities;
using Microsoft.Teams.Core;
using Microsoft.Teams.Core.Schema;
@@ -145,6 +147,81 @@ public async Task FinalizeAsync_AfterOnlyInformative_ThrowsInvalidOperationExcep
await Assert.ThrowsAsync(() => writer.FinalizeResponseAsync());
}
+ // ── MessageActivity-based finalize ────────────────────────────────────────
+
+ [Fact]
+ public async Task FinalizeAsync_WithCustomMessageActivity_UsesCallerSuppliedContent()
+ {
+ (TeamsStreamingWriter writer, FakeHttpMessageHandler handler) = CreateWriter();
+
+ await writer.AppendResponseAsync("streamed text");
+
+ MessageActivity final = new("explicit text");
+ final.AddFeedback(FeedbackType.Custom);
+
+ await writer.FinalizeResponseAsync(final);
+
+ string finalBody = handler.RequestBodies.Last();
+ // Caller-supplied text wins over accumulated.
+ Assert.Contains("explicit text", finalBody);
+ Assert.DoesNotContain("streamed text", finalBody);
+ // Writer still injects the streamType=final marker.
+ Assert.Contains("\"streamType\": \"final\"", finalBody);
+ // Custom feedback set on the caller's activity is preserved.
+ Assert.Contains("\"feedbackLoop\"", finalBody);
+ Assert.Contains("\"type\": \"custom\"", finalBody);
+ }
+
+ [Fact]
+ public async Task FinalizeAsync_WithMessageActivityWithoutText_FallsBackToAccumulated()
+ {
+ (TeamsStreamingWriter writer, FakeHttpMessageHandler handler) = CreateWriter();
+
+ await writer.AppendResponseAsync("Hello, ");
+ await writer.AppendResponseAsync("world");
+
+ // No Text set on the activity — writer should fill in the accumulated text.
+ MessageActivity final = new();
+ final.AddFeedback(FeedbackType.Default);
+
+ await writer.FinalizeResponseAsync(final);
+
+ string finalBody = handler.RequestBodies.Last();
+ Assert.Contains("Hello, world", finalBody);
+ Assert.Contains("\"type\": \"default\"", finalBody);
+ }
+
+ [Fact]
+ public async Task FinalizeAsync_AttachmentOnlyReply_RequiresExplicitEmptyText()
+ {
+ (TeamsStreamingWriter writer, FakeHttpMessageHandler handler) = CreateWriter();
+
+ // Note: no AppendResponseAsync — the reply is the attachment only.
+ TeamsAttachment attachment = TeamsAttachment.CreateBuilder()
+ .WithContentType("application/vnd.microsoft.card.adaptive")
+ .WithContent(new JsonObject { ["type"] = "AdaptiveCard", ["version"] = "1.5" })
+ .Build();
+
+ MessageActivity final = new() { Text = "" };
+ final.AddAttachment(attachment);
+
+ await writer.FinalizeResponseAsync(final);
+
+ string finalBody = handler.RequestBodies.Last();
+ Assert.Contains("\"streamType\": \"final\"", finalBody);
+ Assert.Contains("AdaptiveCard", finalBody);
+ }
+
+ [Fact]
+ public async Task FinalizeAsync_EmptyActivityWithNoStreamedText_Throws()
+ {
+ (TeamsStreamingWriter writer, _) = CreateWriter();
+
+ MessageActivity final = new();
+
+ await Assert.ThrowsAsync(() => writer.FinalizeResponseAsync(final));
+ }
+
// ── Shared streamId ───────────────────────────────────────────────────────
[Fact]