From 49d85db763fc90680b4033141a316fe9994368c9 Mon Sep 17 00:00:00 2001 From: Corina <14900841+corinagum@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:45:33 -0800 Subject: [PATCH 1/3] Post Plan completion to the reply chain in 1:1 threads Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Microsoft.Teams.Apps/Plan.cs | 30 ++++++++++--- src/Microsoft.Teams.Apps/PlanOptions.cs | 21 +++++++++ .../PlanTests.cs | 45 +++++++++++++++++++ 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/src/Microsoft.Teams.Apps/Plan.cs b/src/Microsoft.Teams.Apps/Plan.cs index fc6b1e2b..ea219099 100644 --- a/src/Microsoft.Teams.Apps/Plan.cs +++ b/src/Microsoft.Teams.Apps/Plan.cs @@ -13,9 +13,11 @@ namespace Microsoft.Teams.Apps; /// Explicit, prompt-bound plan updates. This helper does not own a workflow or a stream. /// -/// Persist the two IDs and supply a service-scoped API client when reconstructing for delayed work. +/// Persist the IDs (and , when set) and supply a service-scoped API client when reconstructing for delayed work. /// Targeted Plan scenarios are not supported. Plan activities use ordinary create routing, and /// does not inherit targeting from the originating context. +/// In a chat thread, CompleteAsync is posted to the reply chain like ctx.send. +/// Typing updates (start, update, stop, error) are sent to the conversation, as ctx.send does for typing activities. /// [Experimental("ExperimentalTeamsPlan")] public sealed class Plan @@ -30,6 +32,11 @@ public sealed class Plan [JsonPropertyName("replyToId")] public string ReplyToId { get; } + /// The reply chain (thread) of a chat that the completion is posted to, if any. + [JsonPropertyName("replyChain")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public PlanReplyChain? ReplyChain { get; } + /// Raised after a successful send; typing acknowledgements have no message response. public event EventHandler? Sent; @@ -46,10 +53,17 @@ public Plan(ApiClient apiClient, PlanOptions options) ArgumentNullException.ThrowIfNull(options); ArgumentException.ThrowIfNullOrWhiteSpace(options.ConversationId); ArgumentException.ThrowIfNullOrWhiteSpace(options.ReplyToId); + if (options.ReplyChain is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(options.ReplyChain.ChatOrChannelId); + ArgumentException.ThrowIfNullOrWhiteSpace(options.ReplyChain.ReplyChainId); + } + _client = apiClient.Conversations ?? throw new ArgumentException("Supply an API client scoped to the conversation's service URL.", nameof(apiClient)); ConversationId = options.ConversationId; ReplyToId = options.ReplyToId; + ReplyChain = options.ReplyChain; } /// Creates a standalone helper bound to the incoming activity's own ID. @@ -108,7 +122,7 @@ public MessageActivityInput Completion() throw new ArgumentException("CompleteAsync cannot send non-complete Plan operations.", nameof(message)); if (plans.Length == 0) activity.AddEntity(new PlanInfoEntity { PlanType = PlanTypes.Complete }); - return SendActivityAsync(activity, cancellationToken); + return SendActivityAsync(activity, ReplyChain, cancellationToken); } private async Task SendUpdateAsync(PlanType operation, IEnumerable? steps, CancellationToken cancellationToken) @@ -119,14 +133,18 @@ private async Task SendUpdateAsync(PlanType operation, IEnumerable? st }; // Snapshot caller-owned steps and sources before handing the activity to the client. activity = JsonSerializer.Deserialize(activity.ToJson(), TeamsActivityInputJsonContext.Default.TeamsActivityInput)!; - await SendActivityAsync(activity, cancellationToken).ConfigureAwait(false); + await SendActivityAsync(activity, null, cancellationToken).ConfigureAwait(false); } - private async Task SendActivityAsync(TeamsActivityInput activity, CancellationToken cancellationToken) + private async Task SendActivityAsync(TeamsActivityInput activity, PlanReplyChain? replyChain, CancellationToken cancellationToken) { activity.ReplyToId = ReplyToId; - SendActivityResponse? result = await _client.CreateActivityAsync( - ConversationId, activity, cancellationToken: cancellationToken).ConfigureAwait(false); +#pragma warning disable ExperimentalTeamsThreading // the Plan completion is placed like the stable send methods + SendActivityResponse? result = replyChain is null + ? await _client.CreateActivityAsync(ConversationId, activity, cancellationToken: cancellationToken).ConfigureAwait(false) + : await _client.CreateReplyChainActivityAsync( + replyChain.ChatOrChannelId, replyChain.ReplyChainId, activity, cancellationToken: cancellationToken).ConfigureAwait(false); +#pragma warning restore ExperimentalTeamsThreading if (activity.Type == TeamsActivityTypes.Typing) result = null; Sent?.Invoke(this, new PlanSentEventArgs(result)); diff --git a/src/Microsoft.Teams.Apps/PlanOptions.cs b/src/Microsoft.Teams.Apps/PlanOptions.cs index 50cff1e7..7726bb0d 100644 --- a/src/Microsoft.Teams.Apps/PlanOptions.cs +++ b/src/Microsoft.Teams.Apps/PlanOptions.cs @@ -17,4 +17,25 @@ public sealed class PlanOptions /// The originating prompt's activity ID, not a later event or response ID. [JsonPropertyName("replyToId")] public required string ReplyToId { get; init; } + + /// + /// The reply chain (thread) of a chat that posts to, as ctx.send does. + /// Omit it when already identifies the thread, as in a channel, or when the prompt is not inside a thread. + /// + [JsonPropertyName("replyChain")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public PlanReplyChain? ReplyChain { get; init; } +} + +/// The reply chain (thread) of a chat that a Plan completion is posted to, taken from the inbound channelData. +[Experimental("ExperimentalTeamsPlan")] +public sealed class PlanReplyChain +{ + /// The chat id, channelData.chat.id. + [JsonPropertyName("chatOrChannelId")] + public required string ChatOrChannelId { get; init; } + + /// The id of the message at the root of the reply chain, channelData.replyChain.id. + [JsonPropertyName("replyChainId")] + public required string ReplyChainId { get; init; } } diff --git a/test/Microsoft.Teams.Apps.UnitTests/PlanTests.cs b/test/Microsoft.Teams.Apps.UnitTests/PlanTests.cs index bc6daf21..9bd7d35c 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/PlanTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/PlanTests.cs @@ -446,4 +446,49 @@ public async Task LowLevelReply_CreatesWithoutMutatingPlanPayload(bool legacy) Assert.EndsWith("/activities/", handler.Requests[0].RequestUri!.AbsolutePath, StringComparison.Ordinal); Assert.Equal(PromptId, Assert.Single(handler.Bodies)["replyToId"]!.GetValue()); } + + [Fact] + public async Task Rebuilt_WithSavedReplyChain_CompletesInThatChainAndKeepsTheShape() + { + (ApiClient api, RecordingHandler handler) = CreateClient(); + const string saved = """{"conversationId":"opaque-conversation","replyToId":"prompt-id","replyChain":{"chatOrChannelId":"19:chat@thread.v2","replyChainId":"root-1"}}"""; + + Plan restored = new(api, JsonSerializer.Deserialize(saved)!); + await restored.CompleteAsync("Done"); + + HttpRequestMessage request = Assert.Single(handler.Requests); + Assert.EndsWith("/v3/conversations/19%3Achat%40thread.v2/replyChains/root-1", request.RequestUri!.AbsolutePath); + Assert.Equal(PromptId, Assert.Single(handler.Bodies)["replyToId"]!.GetValue()); + Assert.Equal(saved, JsonSerializer.Serialize(restored)); + } + + [Fact] + public async Task Rebuilt_WithSavedReplyChain_SendsTypingToTheConversation() + { + (ApiClient api, RecordingHandler handler) = CreateClient(); + PlanOptions options = new() + { + ConversationId = ConversationId, + ReplyToId = PromptId, + ReplyChain = new PlanReplyChain { ChatOrChannelId = "19:chat@thread.v2", ReplyChainId = "root-1" }, + }; + + await new Plan(api, options).StartAsync([]); + + HttpRequestMessage request = Assert.Single(handler.Requests); + Assert.DoesNotContain("/replyChains/", request.RequestUri!.AbsolutePath); + } + + [Fact] + public async Task Rebuilt_FromShapeWithoutReplyChain_CompletesInTheConversation() + { + (ApiClient api, RecordingHandler handler) = CreateClient(); + const string saved = """{"conversationId":"chat;messageid=thread-root","replyToId":"prompt-id"}"""; + + Plan restored = new(api, JsonSerializer.Deserialize(saved)!); + await restored.CompleteAsync("Done"); + + Assert.DoesNotContain("/replyChains/", Assert.Single(handler.Requests).RequestUri!.AbsolutePath); + Assert.Equal(saved, JsonSerializer.Serialize(restored)); + } } From a506ab38612ab4a926ed43952445c15bc4dd962a Mon Sep 17 00:00:00 2001 From: Corina <14900841+corinagum@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:46:21 -0800 Subject: [PATCH 2/3] Fill the Plan reply chain from channelData in ctx.plan() Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- samples/PlanBot/PlanDemo.cs | 5 +- src/Microsoft.Teams.Apps/Plan.cs | 6 ++- .../ContextReplyChainPlacementTests.cs | 53 +++++++++++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/samples/PlanBot/PlanDemo.cs b/samples/PlanBot/PlanDemo.cs index eb8ff862..73bd21c3 100644 --- a/samples/PlanBot/PlanDemo.cs +++ b/samples/PlanBot/PlanDemo.cs @@ -52,9 +52,10 @@ await plan.StartAsync( string saved = JsonSerializer.Serialize(new PlanOptions { ConversationId = plan.ConversationId, - ReplyToId = plan.ReplyToId + ReplyToId = plan.ReplyToId, + ReplyChain = plan.ReplyChain }); - // Persist only the IDs. The scoped client stays in this process, not the Context. + // Persist only the binding. The scoped client stays in this process, not the Context. Task continuation = ContinueLaterAsync(context.Api, saved, stopping); _continuations.TryAdd(continuation, 0); _ = continuation.ContinueWith(task => _continuations.TryRemove(task, out _), diff --git a/src/Microsoft.Teams.Apps/Plan.cs b/src/Microsoft.Teams.Apps/Plan.cs index ea219099..5933be28 100644 --- a/src/Microsoft.Teams.Apps/Plan.cs +++ b/src/Microsoft.Teams.Apps/Plan.cs @@ -74,7 +74,11 @@ public static Plan CreateFromContext(Context context) wher ?? throw new ArgumentException("Activity must have a Conversation with an Id.", nameof(context)); string replyToId = context.Activity.Id ?? throw new ArgumentException("Activity must have an Id.", nameof(context)); - return new Plan(context.Api, new PlanOptions { ConversationId = conversationId, ReplyToId = replyToId }); + ReplyChainPlacement? placement = context.GetInboundReplyChainPlacement(); + PlanReplyChain? replyChain = placement is { InChannel: false } chat + ? new PlanReplyChain { ChatOrChannelId = chat.ChatOrChannelId, ReplyChainId = chat.ReplyChainId } + : null; + return new Plan(context.Api, new PlanOptions { ConversationId = conversationId, ReplyToId = replyToId, ReplyChain = replyChain }); } /// Starts a plan with the supplied steps. diff --git a/test/Microsoft.Teams.Apps.UnitTests/ContextReplyChainPlacementTests.cs b/test/Microsoft.Teams.Apps.UnitTests/ContextReplyChainPlacementTests.cs index d27028e7..aa849e00 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/ContextReplyChainPlacementTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/ContextReplyChainPlacementTests.cs @@ -1092,6 +1092,59 @@ public async Task CreateFromContext_ReplyChainRouteNotFound_PropagatesWithoutSta Assert.Equal(0, harness.Send.Calls); } + [Fact] + public async Task PlanCompleteAsync_InboundInsideReplyChainInAChat_RepliesInThatChain() + { + Harness harness = CreateHarness(); + Plan plan = Plan.CreateFromContext(harness.ContextFor(InboundInThread())); + + await plan.CompleteAsync("Done"); + + Assert.Equal(ChatId, harness.Reply.ConversationId); + Assert.Equal("root-1", harness.Reply.ActivityId); + Assert.Equal("1793257586005", harness.Reply.Activity!.ReplyToId); + Assert.Equal(0, harness.Send.Calls); + } + + [Fact] + public async Task PlanCompleteAsync_InboundTopLevelChatMessage_SendsToInboundConversation() + { + Harness harness = CreateHarness(); + Plan plan = Plan.CreateFromContext(harness.ContextFor(Inbound( + conversationId: OpaqueConversationId, + id: "root-1", + channelData: new { chat = new { id = ChatId }, replyChain = new { id = "root-1" } }))); + + await plan.CompleteAsync("Done"); + + Assert.Equal(OpaqueConversationId, harness.Send.ConversationId); + Assert.Equal(0, harness.Reply.Calls); + } + + [Fact] + public async Task PlanCompleteAsync_InboundInAChannel_SendsToInboundConversation() + { + Harness harness = CreateHarness(); + Plan plan = Plan.CreateFromContext(harness.ContextFor(InboundInChannelThread())); + + await plan.CompleteAsync("Done"); + + Assert.Equal(ChannelThreadConversationId, harness.Send.ConversationId); + Assert.Equal(0, harness.Reply.Calls); + } + + [Fact] + public async Task PlanTypingUpdates_InboundInsideReplyChainInAChat_SendToInboundConversation() + { + Harness harness = CreateHarness(); + Plan plan = Plan.CreateFromContext(harness.ContextFor(InboundInThread())); + + await plan.StartAsync([]); + + Assert.Equal(OpaqueConversationId, harness.Send.ConversationId); + Assert.Equal(0, harness.Reply.Calls); + } + private static MessageActivity InboundInChannelThread() => Inbound( conversationId: ChannelThreadConversationId, id: "1793257586005", From 58824a1dde5f14b6f06225f92dcf4209aca4ca5b Mon Sep 17 00:00:00 2001 From: Corina <14900841+corinagum@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:13:35 -0800 Subject: [PATCH 3/3] docs: describe Plan placement in CreateFromContext Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Microsoft.Teams.Apps/Plan.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Microsoft.Teams.Apps/Plan.cs b/src/Microsoft.Teams.Apps/Plan.cs index 5933be28..12278db4 100644 --- a/src/Microsoft.Teams.Apps/Plan.cs +++ b/src/Microsoft.Teams.Apps/Plan.cs @@ -67,6 +67,10 @@ public Plan(ApiClient apiClient, PlanOptions options) } /// Creates a standalone helper bound to the incoming activity's own ID. + /// + /// In a chat thread, a standalone CompleteAsync is posted to the reply chain, as ctx.send does. + /// Typing updates, completions in a channel, and prompts outside a thread use the conversation. + /// public static Plan CreateFromContext(Context context) where TActivity : TeamsActivity { ArgumentNullException.ThrowIfNull(context);