Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions samples/PlanBot/PlanDemo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 _),
Expand Down
40 changes: 33 additions & 7 deletions src/Microsoft.Teams.Apps/Plan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ namespace Microsoft.Teams.Apps;

/// <summary>Explicit, prompt-bound plan updates. This helper does not own a workflow or a stream.</summary>
/// <remarks>
/// Persist the two IDs and supply a service-scoped API client when reconstructing for delayed work.
/// Persist the IDs (and <see cref="ReplyChain"/>, 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
/// <see cref="Completion"/> does not inherit targeting from the originating context.
/// In a chat thread, <c>CompleteAsync</c> is posted to the reply chain like <c>ctx.send</c>.
/// Typing updates (start, update, stop, error) are sent to the conversation, as <c>ctx.send</c> does for typing activities.
/// </remarks>
[Experimental("ExperimentalTeamsPlan")]
public sealed class Plan
Expand All @@ -30,6 +32,11 @@ public sealed class Plan
[JsonPropertyName("replyToId")]
public string ReplyToId { get; }

/// <summary>The reply chain (thread) of a chat that the completion is posted to, if any.</summary>
[JsonPropertyName("replyChain")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public PlanReplyChain? ReplyChain { get; }

/// <summary>Raised after a successful send; typing acknowledgements have no message response.</summary>
public event EventHandler<PlanSentEventArgs>? Sent;

Expand All @@ -46,21 +53,36 @@ 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;
}

/// <summary>Creates a standalone helper bound to the incoming activity's own ID.</summary>
/// <remarks>
/// In a chat thread, a standalone <c>CompleteAsync</c> is posted to the reply chain, as <c>ctx.send</c> does.
/// Typing updates, completions in a channel, and prompts outside a thread use the conversation.
/// </remarks>
public static Plan CreateFromContext<TActivity>(Context<TActivity> context) where TActivity : TeamsActivity
{
ArgumentNullException.ThrowIfNull(context);
string conversationId = context.Activity.Conversation?.Id
?? 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 });
}

/// <summary>Starts a plan with the supplied steps.</summary>
Expand Down Expand Up @@ -108,7 +130,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<PlanStep>? steps, CancellationToken cancellationToken)
Expand All @@ -119,14 +141,18 @@ private async Task SendUpdateAsync(PlanType operation, IEnumerable<PlanStep>? 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<SendActivityResponse?> SendActivityAsync(TeamsActivityInput activity, CancellationToken cancellationToken)
private async Task<SendActivityResponse?> 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));
Expand Down
21 changes: 21 additions & 0 deletions src/Microsoft.Teams.Apps/PlanOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,25 @@ public sealed class PlanOptions
/// <summary>The originating prompt's activity ID, not a later event or response ID.</summary>
[JsonPropertyName("replyToId")]
public required string ReplyToId { get; init; }

/// <summary>
/// The reply chain (thread) of a chat that <see cref="Plan.CompleteAsync(string, CancellationToken)"/> posts to, as <c>ctx.send</c> does.
/// Omit it when <see cref="ConversationId"/> already identifies the thread, as in a channel, or when the prompt is not inside a thread.
/// </summary>
[JsonPropertyName("replyChain")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public PlanReplyChain? ReplyChain { get; init; }
}

/// <summary>The reply chain (thread) of a chat that a Plan completion is posted to, taken from the inbound <c>channelData</c>.</summary>
[Experimental("ExperimentalTeamsPlan")]
public sealed class PlanReplyChain
{
/// <summary>The chat id, <c>channelData.chat.id</c>.</summary>
[JsonPropertyName("chatOrChannelId")]
public required string ChatOrChannelId { get; init; }

/// <summary>The id of the message at the root of the reply chain, <c>channelData.replyChain.id</c>.</summary>
[JsonPropertyName("replyChainId")]
public required string ReplyChainId { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
45 changes: 45 additions & 0 deletions test/Microsoft.Teams.Apps.UnitTests/PlanTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>());
}

[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<PlanOptions>(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<string>());
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<PlanOptions>(saved)!);
await restored.CompleteAsync("Done");

Assert.DoesNotContain("/replyChains/", Assert.Single(handler.Requests).RequestUri!.AbsolutePath);
Assert.Equal(saved, JsonSerializer.Serialize(restored));
}
}
Loading