diff --git a/samples/StreamingBot/Program.cs b/samples/StreamingBot/Program.cs index 9fcac990..61c5ba37 100644 --- a/samples/StreamingBot/Program.cs +++ b/samples/StreamingBot/Program.cs @@ -13,32 +13,56 @@ WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args); builder.Services.AddTeamsBotApplication(); -string endpoint = builder.Configuration["AzureOpenAI:Endpoint"] ?? throw new InvalidOperationException("AzureOpenAI:Endpoint is required."); -string apiKey = builder.Configuration["AzureOpenAI:ApiKey"] ?? throw new InvalidOperationException("AzureOpenAI:ApiKey is required."); -string deployment = builder.Configuration["AzureOpenAI:Deployment"] ?? throw new InvalidOperationException("AzureOpenAI:Deployment is required."); - -builder.Services.AddSingleton(_ => new AzureOpenAIClient(new Uri(endpoint), new ApiKeyCredential(apiKey))); -builder.Services.AddSingleton(sp => - sp.GetRequiredService() - .GetChatClient(deployment) - .AsIChatClient()); +// Azure OpenAI is optional. When all three settings are present the default message path streams +// live model output; otherwise it falls back to a canned streamed response so the sample still runs. +string? endpoint = builder.Configuration["AzureOpenAI:Endpoint"]; +string? apiKey = builder.Configuration["AzureOpenAI:ApiKey"]; +string? deployment = builder.Configuration["AzureOpenAI:Deployment"]; +bool aiConfigured = !string.IsNullOrWhiteSpace(endpoint) + && !string.IsNullOrWhiteSpace(apiKey) + && !string.IsNullOrWhiteSpace(deployment); + +if (aiConfigured) +{ + builder.Services.AddSingleton(_ => new AzureOpenAIClient(new Uri(endpoint!), new ApiKeyCredential(apiKey!))); + builder.Services.AddSingleton(sp => + sp.GetRequiredService() + .GetChatClient(deployment!) + .AsIChatClient()); +} WebApplication webApp = builder.Build(); TeamsBotApplication teamsApp = webApp.UseTeamsBotApplication(); -IChatClient chatClient = webApp.Services.GetRequiredService(); +IChatClient? chatClient = webApp.Services.GetService(); teamsApp.OnMessage(async (context, cancellationToken) => { TeamsStreamingWriter writer = TeamsStreamingWriter.CreateFromContext(context); - // Send "multi-stream" to demo reusing the same writer for a second streamed message - // after FinalizeResponseAsync (stream reuse). This path does not call OpenAI. - if ((context.Activity.Text ?? string.Empty).Replace("-", " ").Contains("multi stream", StringComparison.OrdinalIgnoreCase)) + string normalized = (context.Activity.Text ?? string.Empty).Replace("-", " "); + + // "multi stream": reuse the same writer for a second streamed message after finalize. + if (normalized.Contains("multi stream", StringComparison.OrdinalIgnoreCase)) { await RunMultiStreamDemoAsync(writer, cancellationToken); return; } + // "extended markdown": stream content that only renders correctly under extended markdown. + if (normalized.Contains("extended markdown", StringComparison.OrdinalIgnoreCase)) + { + await RunExtendedMarkdownDemoAsync(writer, cancellationToken); + return; + } + + // Default path: stream live model output when Azure OpenAI is configured, otherwise a + // canned streamed response so the sample works without any AI backend. + if (chatClient is null) + { + await RunCannedResponseAsync(writer, cancellationToken); + return; + } + await writer.SendInformativeUpdateAsync("Thinking…", cancellationToken); await Task.Delay(500, cancellationToken); await writer.SendInformativeUpdateAsync("Thinking again !!!", cancellationToken); @@ -75,7 +99,7 @@ [new ChatMessage(ChatRole.User, userText)], }; TeamsAttachment card = TeamsAttachment.CreateBuilder() - .WithAdaptiveCard(CreateResponseCard(deployment)) + .WithAdaptiveCard(CreateResponseCard(deployment!)) .Build(); MessageActivityInput final = new MessageActivityInput() @@ -94,6 +118,27 @@ [new ChatMessage(ChatRole.User, userText)], return new InvokeResponse(200); }); +static async Task RunCannedResponseAsync(TeamsStreamingWriter writer, CancellationToken cancellationToken) +{ + string[] messages = + [ + "Azure OpenAI isn't configured, so here's a canned streamed reply. ", + "Set AzureOpenAI:Endpoint, :ApiKey, and :Deployment to stream live model output instead. ", + "You can also try `extended markdown`, `extended markdown before`, or `multi stream`.", + ]; + + await writer.SendInformativeUpdateAsync("Thinking…", cancellationToken); + await Task.Delay(500, cancellationToken); + + foreach (string message in messages) + { + await Task.Delay(500, cancellationToken); + await writer.AppendResponseAsync(message, cancellationToken); + } + + await writer.FinalizeResponseAsync(cancellationToken: cancellationToken); +} + static async Task RunMultiStreamDemoAsync(TeamsStreamingWriter writer, CancellationToken cancellationToken) { string[] firstStreamMessages = @@ -143,6 +188,34 @@ static async Task RunMultiStreamDemoAsync(TeamsStreamingWriter writer, Cancellat await writer.FinalizeResponseAsync(cancellationToken: cancellationToken); } +static async Task RunExtendedMarkdownDemoAsync(TeamsStreamingWriter writer, CancellationToken cancellationToken) +{ + // Task lists ("- [x]") and strikethrough ("~~...~~") only render under extended markdown. + string[] messages = + [ + "**Extended markdown stream** — rendering features plain markdown can't:\n\n", + "- [x] Sent with `textFormat: 'extendedmarkdown'`\n", + "- [x] Task list items render as real checkboxes\n", + "- [ ] ~~Under plain markdown these would be literal `[ ]` text~~\n", + "- [x] Strikethrough renders too\n", + ]; + +#pragma warning disable ExperimentalTeamsExtendedMarkdown + // The informative update carries its own format (plain markdown here); the streamed chunks + // set extended markdown per-chunk via MessageActivityInput, which also formats the final message. + await writer.SendInformativeUpdateAsync("Starting the *extended* markdown stream…", TextFormats.Markdown, cancellationToken); + await Task.Delay(1000, cancellationToken); + + foreach (string message in messages) + { + await Task.Delay(500, cancellationToken); + await writer.AppendResponseAsync(new MessageActivityInput().WithText(message, TextFormats.ExtendedMarkdown), cancellationToken); + } +#pragma warning restore ExperimentalTeamsExtendedMarkdown + + await writer.FinalizeResponseAsync(cancellationToken: cancellationToken); +} + static TeamsAttachment CreateSimpleCard() { AdaptiveCard card = new([ diff --git a/samples/StreamingBot/README.md b/samples/StreamingBot/README.md index 2c6189ac..389ee7ff 100644 --- a/samples/StreamingBot/README.md +++ b/samples/StreamingBot/README.md @@ -5,7 +5,8 @@ Shows streaming responses in Teams using `TeamsStreamingWriter`, including incre ## Prerequisites - Bot registered and installed in Teams. -- Azure OpenAI configured: +- Azure OpenAI (optional) — enables live model output on the default message path. Without it, the + default path streams a canned response instead. To enable, set: - `AzureOpenAI__Endpoint` - `AzureOpenAI__ApiKey` - `AzureOpenAI__Deployment` @@ -21,7 +22,8 @@ Shows streaming responses in Teams using `TeamsStreamingWriter`, including incre | Input | Behavior | |---|---| -| any text | Streams progress + model output, then sends final card/citation response | +| any text | Streams progress + model output (or a canned response when Azure OpenAI isn't configured), then sends the final response | +| `extended markdown` | Streams content that demonstrates extended-markdown features (task lists + strikethrough), setting the format per-chunk via `AppendResponseAsync(new MessageActivityInput().WithText(text, TextFormats.ExtendedMarkdown))` — the fix under test | | `multi stream` | Runs two streamed responses back-to-back using the same writer | ## Running the Sample diff --git a/src/Microsoft.Teams.Apps/TeamsStreamingWriter.Activity.cs b/src/Microsoft.Teams.Apps/TeamsStreamingWriter.Activity.cs index 97824169..79e133be 100644 --- a/src/Microsoft.Teams.Apps/TeamsStreamingWriter.Activity.cs +++ b/src/Microsoft.Teams.Apps/TeamsStreamingWriter.Activity.cs @@ -29,6 +29,12 @@ internal StreamingActivityInput() : base(TeamsActivityTypes.Typing) [JsonPropertyName("text")] public string? Text { get; set; } + /// + /// Gets or sets the text format. See for common values. + /// + [JsonPropertyName("textFormat")] + public TextFormat? TextFormat { get; set; } + /// /// Gets or sets the stream info entity for this streaming activity. /// @@ -78,6 +84,15 @@ public StreamingActivityInputBuilder WithText(string text) return this; } + /// + /// Sets the format of the streaming chunk's text. See . + /// + public StreamingActivityInputBuilder WithTextFormat(TextFormat textFormat) + { + _activity.TextFormat = textFormat; + return this; + } + /// /// Sets the stream metadata for this chunk (writes channel data and adds a ). /// diff --git a/src/Microsoft.Teams.Apps/TeamsStreamingWriter.cs b/src/Microsoft.Teams.Apps/TeamsStreamingWriter.cs index cdf1e1c6..2c60ef52 100644 --- a/src/Microsoft.Teams.Apps/TeamsStreamingWriter.cs +++ b/src/Microsoft.Teams.Apps/TeamsStreamingWriter.cs @@ -38,6 +38,19 @@ namespace Microsoft.Teams.Apps; /// await writer.FinalizeResponseAsync(final); /// /// +/// To stream content in a non-default (ex. extended markdown), pass the +/// format on the chunk itself: use +/// with a set, or +/// for an informative update. +/// The most recent streamed chunk's format is also used as the final message's format, unless the caller +/// sets explicitly on the activity passed to +/// . +/// +/// await writer.AppendResponseAsync( +/// new MessageActivityInput().WithText("- [x] Rendered as extended markdown", TextFormats.ExtendedMarkdown)); +/// await writer.FinalizeResponseAsync(); +/// +/// /// The writer is reusable: appending or sending an informative update after /// reopens the stream on the same instance and starts a new /// streamed message. Finalizing is idempotent until the next append/informative update. @@ -64,6 +77,10 @@ public sealed class TeamsStreamingWriter private bool _timedOut; private readonly System.Text.StringBuilder _accumulated = new(); private DateTime _lastChunkSent = DateTime.MinValue; + // The most recent streamed chunk's text format (set by AppendResponseAsync with a + // MessageActivityInput carrying a TextFormat). Applied to subsequent streamed chunks and + // used as the final message's default format unless the caller sets one explicitly. + private TextFormat? _textFormat; /// /// Whether the stream has been cancelled, for example when the user pressed the Stop button. @@ -96,9 +113,23 @@ public static TeamsStreamingWriter CreateFromContext(Context /// Sends an informative placeholder (streamType = "informative"). - /// Optional — if omitted the first call begins the stream. + /// Optional — if omitted the first call begins the stream. /// - public async Task SendInformativeUpdateAsync(string text, CancellationToken cancellationToken = default) + public Task SendInformativeUpdateAsync(string text, CancellationToken cancellationToken = default) + => SendInformativeUpdateCoreAsync(text, null, cancellationToken); + + /// + /// Sends an informative placeholder (streamType = "informative") rendered with the given + /// (ex. extended markdown). + /// + /// + /// The format applies to this informative chunk only; it does not change the format of + /// subsequent streamed chunks or the final message. + /// + public Task SendInformativeUpdateAsync(string text, TextFormat textFormat, CancellationToken cancellationToken = default) + => SendInformativeUpdateCoreAsync(text, textFormat, cancellationToken); + + private async Task SendInformativeUpdateCoreAsync(string text, TextFormat? textFormat, CancellationToken cancellationToken) { if (_cancelled) return; @@ -115,7 +146,7 @@ public async Task SendInformativeUpdateAsync(string text, CancellationToken canc _sequence++; _logger.LogDebug("Sending informative streaming update (sequence {Sequence}).", _sequence); - SendActivityResponse? response = await TrySendChunkAsync(BuildActivity(text, StreamTypes.Informative), cancellationToken).ConfigureAwait(false); + SendActivityResponse? response = await TrySendChunkAsync(BuildActivity(text, StreamTypes.Informative, textFormat), cancellationToken).ConfigureAwait(false); _streamId ??= response?.Id; _logger.LogDebug("Stream started with streamId '{StreamId}'.", _streamId); } @@ -164,6 +195,26 @@ public async Task AppendResponseAsync(string chunk, CancellationToken cancellati _lastChunkSent = DateTime.UtcNow; } + /// + /// Appends 's to the accumulated + /// text and sends the full accumulated text as an intermediate streaming update, rendered with + /// 's when set. + /// + /// + /// Only and + /// are honored for intermediate chunks; attachments, entities, and other properties belong on the + /// final message passed to . When carries + /// a , it becomes the format of this and subsequent + /// streamed chunks (last-wins) and the final message's default format. + /// + public Task AppendResponseAsync(MessageActivityInput chunk, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(chunk); + if (chunk.TextFormat is not null) + _textFormat = chunk.TextFormat; + return AppendResponseAsync(chunk.Text ?? string.Empty, cancellationToken); + } + /// /// Sends the final streaming activity and marks the stream complete. /// @@ -193,6 +244,9 @@ public async Task FinalizeResponseAsync(MessageActivityInput? final = null, Canc final ??= new MessageActivityInput(); final.Text ??= _accumulated.ToString(); + // Same format that was applied to the intermediate typing chunks, unless the caller + // set a different one explicitly on the final activity. + final.TextFormat ??= _textFormat; final.ReplyToId = _reference.Id; if (string.IsNullOrEmpty(final.Text) && (final.Attachments == null || final.Attachments.Count == 0)) @@ -371,15 +425,20 @@ private void ResetForNextStream() _timedOut = false; _accumulated.Clear(); _lastChunkSent = DateTime.MinValue; + _textFormat = null; } - private StreamingActivityInput BuildActivity(string text, StreamType streamType) + private StreamingActivityInput BuildActivity(string text, StreamType streamType, TextFormat? textFormatOverride = null) { - StreamingActivityInput activity = StreamingActivityInput.CreateBuilder() + StreamingActivityInputBuilder builder = StreamingActivityInput.CreateBuilder() .WithText(text) - .WithStreamInfo(streamType, _streamId, _sequence) - .Build(); + .WithStreamInfo(streamType, _streamId, _sequence); + + TextFormat? textFormat = textFormatOverride ?? _textFormat; + if (textFormat is not null) + builder = builder.WithTextFormat(textFormat); + StreamingActivityInput activity = builder.Build(); activity.ReplyToId = _reference.Id; return activity; } diff --git a/test/Microsoft.Teams.Apps.UnitTests/TeamsStreamingWriterTests.cs b/test/Microsoft.Teams.Apps.UnitTests/TeamsStreamingWriterTests.cs index cd245761..57f101aa 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/TeamsStreamingWriterTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/TeamsStreamingWriterTests.cs @@ -140,6 +140,112 @@ public async Task FinalizeAsync_SendsFullAccumulatedText() Assert.Contains("\"streamType\": \"final\"", finalBody); } + // ── TextFormat propagation ───────────────────────────────────────────────── + + [Fact] + public async Task PerChunkTextFormat_AppliesToInformativeStreamingAndFinalMessage() + { + (TeamsStreamingWriter writer, FakeHttpMessageHandler handler) = CreateWriter(); + + await writer.SendInformativeUpdateAsync("Thinking…", TextFormats.ExtendedMarkdown); + await writer.AppendResponseAsync(new MessageActivityInput().WithText("Hello, world", TextFormats.ExtendedMarkdown)); + await writer.FinalizeResponseAsync(); + + Assert.Equal(3, handler.RequestBodies.Count); + Assert.Contains("\"textFormat\": \"extendedmarkdown\"", handler.RequestBodies[0]); + Assert.Contains("\"textFormat\": \"extendedmarkdown\"", handler.RequestBodies[1]); + Assert.Contains("\"textFormat\": \"extendedmarkdown\"", handler.RequestBodies[2]); + } + + [Fact] + public async Task WithoutTextFormat_NoTextFormatSentOnChunksOrFinalMessage() + { + (TeamsStreamingWriter writer, FakeHttpMessageHandler handler) = CreateWriter(); + + await writer.AppendResponseAsync("Hello, world"); + await writer.FinalizeResponseAsync(); + + Assert.All(handler.RequestBodies, body => Assert.DoesNotContain("\"textFormat\"", body, StringComparison.Ordinal)); + } + + [Fact] + public async Task AppendWithMessageActivityInput_TextFormatCarriesToFinalMessage() + { + (TeamsStreamingWriter writer, FakeHttpMessageHandler handler) = CreateWriter(); + + // The chunk's format becomes the last-streamed format and is applied to the final message. + await writer.AppendResponseAsync(new MessageActivityInput().WithText("Hello, world", TextFormats.ExtendedMarkdown)); + await writer.FinalizeResponseAsync(); + + Assert.Contains("\"textFormat\": \"extendedmarkdown\"", handler.RequestBodies[0]); + Assert.Contains("\"textFormat\": \"extendedmarkdown\"", handler.RequestBodies.Last()); + } + + [Fact] + public async Task AppendWithMessageActivityInput_LastFormatWins() + { + (TeamsStreamingWriter writer, FakeHttpMessageHandler handler) = CreateWriter(); + + await writer.AppendResponseAsync(new MessageActivityInput().WithText("a", TextFormats.Markdown)); + // Wait out the rate limit so the second chunk actually sends. + await Task.Delay(600); + await writer.AppendResponseAsync(new MessageActivityInput().WithText("b", TextFormats.ExtendedMarkdown)); + await writer.FinalizeResponseAsync(); + + // The most recent chunk's format wins for the final message. + Assert.Contains("\"textFormat\": \"extendedmarkdown\"", handler.RequestBodies.Last()); + } + + [Fact] + public async Task InformativeTextFormat_DoesNotAffectFinalMessageFormat() + { + (TeamsStreamingWriter writer, FakeHttpMessageHandler handler) = CreateWriter(); + + // An informative-only format must not leak into the streamed/final message. + await writer.SendInformativeUpdateAsync("Thinking…", TextFormats.ExtendedMarkdown); + await writer.AppendResponseAsync("Hello, world"); + await writer.FinalizeResponseAsync(); + + Assert.Contains("\"textFormat\": \"extendedmarkdown\"", handler.RequestBodies[0]); + Assert.DoesNotContain("\"textFormat\"", handler.RequestBodies[1], StringComparison.Ordinal); + Assert.DoesNotContain("\"textFormat\"", handler.RequestBodies.Last(), StringComparison.Ordinal); + } + + [Fact] + public async Task FinalizeAsync_ExplicitTextFormatOnFinalActivity_OverridesStreamedFormat() + { + (TeamsStreamingWriter writer, FakeHttpMessageHandler handler) = CreateWriter(); + + await writer.AppendResponseAsync(new MessageActivityInput().WithText("Hello, world", TextFormats.ExtendedMarkdown)); + + MessageActivityInput final = new MessageActivityInput().WithTextFormat(TextFormats.Markdown); + await writer.FinalizeResponseAsync(final); + + // Intermediate chunk carried the streamed format... + Assert.Contains("\"textFormat\": \"extendedmarkdown\"", handler.RequestBodies[0]); + // ...but the caller's explicit format on the final activity wins there. + string finalBody = handler.RequestBodies.Last(); + Assert.Contains("\"textFormat\": \"markdown\"", finalBody); + Assert.DoesNotContain("extendedmarkdown", finalBody); + } + + [Fact] + public async Task StreamedTextFormat_IsResetWhenStreamIsReusedAfterFinalize() + { + (TeamsStreamingWriter writer, FakeHttpMessageHandler handler) = CreateWriter(); + + await writer.AppendResponseAsync(new MessageActivityInput().WithText("First message", TextFormats.ExtendedMarkdown)); + await writer.FinalizeResponseAsync(); + + // Reopening the stream with a plain append should not carry the previous stream's format forward. + await writer.AppendResponseAsync("Second message"); + await writer.FinalizeResponseAsync(); + + string secondStreamFinalBody = handler.RequestBodies.Last(); + Assert.Contains("Second message", secondStreamFinalBody); + Assert.DoesNotContain("\"textFormat\"", secondStreamFinalBody); + } + [Fact] public async Task FinalizeAsync_WithNoAppendCalls_ThrowsInvalidOperationException() {