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);