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]