From b708e2507dea4261899e82066d2a57a161b72748 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 11 Jul 2025 10:20:40 +0100 Subject: [PATCH] Add PoC CopilotStudio agent (#84) * Add project and skeleton files. * Add CopilotStudioAgent implementation * Add CopilotStudio integration tests * Fix typos and PR feedback. * Fix .net framework build errors. * Address PR comments * Remove temp test. * Add rawresponse for streaming updates and more comments. * Add TODO to review streaming updates * Address PR comments and fix some issues with streaming messages. * Map additional properties to agent reponses * Update CopilotStudio integration tests to match new approach. * Update copilot studio namespaces/project names to match new naming * Add todo's for AIContent types. * Remove files from PR. * Fix up sln file. * Update .gitignore. * Remove duplicate package version items. --- .gitignore | 5 +- .vs/VSWorkspaceState.json | 7 - .vs/af1/v17/.wsuo | Bin 12288 -> 0 bytes .vs/af1/v17/DocumentLayout.json | 27 --- dotnet/Directory.Packages.props | 24 +-- dotnet/agent-framework-dotnet.slnx | 4 + .../Agent.cs | 16 +- .../ActivityProcessor.cs | 54 ++++++ .../CopilotStudioAgent.cs | 160 ++++++++++++++++++ .../CopilotStudioAgentThread.cs | 10 ++ ....Extensions.AI.Agents.CopilotStudio.csproj | 30 ++++ .../ChatClientAgentExtensions.cs | 8 +- .../RunStreamingTests.cs | 12 ++ .../RunTests.cs | 15 ++ ...AIAgentsPersistent.IntegrationTests.csproj | 4 + .../CopilotStudio.IntegrationTests.csproj | 19 +++ .../CopilotStudioFixture.cs | 72 ++++++++ .../CopilotStudioRunStreamingTests.cs | 15 ++ .../CopilotStudioRunTests.cs | 15 ++ .../CopilotStudioAgentConfiguration.cs | 15 ++ .../CopilotStudioConnectionSettings.cs | 61 +++++++ .../Support/CopilotStudioTokenHandler.cs | 142 ++++++++++++++++ 22 files changed, 658 insertions(+), 57 deletions(-) delete mode 100644 .vs/VSWorkspaceState.json delete mode 100644 .vs/af1/v17/.wsuo delete mode 100644 .vs/af1/v17/DocumentLayout.json create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/ActivityProcessor.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/CopilotStudioAgent.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/CopilotStudioAgentThread.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/Microsoft.Extensions.AI.Agents.CopilotStudio.csproj create mode 100644 dotnet/tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj create mode 100644 dotnet/tests/CopilotStudio.IntegrationTests/CopilotStudioFixture.cs create mode 100644 dotnet/tests/CopilotStudio.IntegrationTests/CopilotStudioRunStreamingTests.cs create mode 100644 dotnet/tests/CopilotStudio.IntegrationTests/CopilotStudioRunTests.cs create mode 100644 dotnet/tests/CopilotStudio.IntegrationTests/Support/CopilotStudioAgentConfiguration.cs create mode 100644 dotnet/tests/CopilotStudio.IntegrationTests/Support/CopilotStudioConnectionSettings.cs create mode 100644 dotnet/tests/CopilotStudio.IntegrationTests/Support/CopilotStudioTokenHandler.cs diff --git a/.gitignore b/.gitignore index af2da9f736..b22a446a9d 100644 --- a/.gitignore +++ b/.gitignore @@ -173,4 +173,7 @@ cython_debug/ # PyPI configuration file .pypirc -.DS_Store \ No newline at end of file +.DS_Store + +# Visual Studio 2015/2017 cache/options directory +.vs/ diff --git a/.vs/VSWorkspaceState.json b/.vs/VSWorkspaceState.json deleted file mode 100644 index 501b8efb8a..0000000000 --- a/.vs/VSWorkspaceState.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "ExpandedNodes": [ - "" - ], - "SelectedNode": "\\agent-framework-dotnet.slnx", - "PreviewInSolutionExplorer": false -} \ No newline at end of file diff --git a/.vs/af1/v17/.wsuo b/.vs/af1/v17/.wsuo deleted file mode 100644 index 920a221516479574db0599aa9ad2f4416e762cde..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12288 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 diff --git a/.vs/af1/v17/DocumentLayout.json b/.vs/af1/v17/DocumentLayout.json deleted file mode 100644 index d15424fbb6..0000000000 --- a/.vs/af1/v17/DocumentLayout.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "Version": 1, - "WorkspaceRootPath": "C:\\Users\\crickman\\source\\repos\\af1\\", - "Documents": [], - "DocumentGroupContainers": [ - { - "Orientation": 0, - "VerticalTabListWidth": 256, - "DocumentGroups": [ - { - "DockedWidth": 200, - "SelectedChildIndex": -1, - "Children": [ - { - "$type": "Bookmark", - "Name": "ST:0:0:{e1b7d1f8-9b3c-49b1-8f4f-bfc63a88835d}" - }, - { - "$type": "Bookmark", - "Name": "ST:0:0:{d78612c7-9962-4b83-95d9-268046dad23a}" - } - ] - } - ] - } - ] -} \ No newline at end of file diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 7277c0c761..c48afe4ebf 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -6,30 +6,34 @@ - + + + + + + + + + + - - - - - - - - - + + + + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 5fb6bffbef..e33215905e 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -11,6 +11,9 @@ + + + @@ -114,6 +117,7 @@ + diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/Agent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/Agent.cs index 5e7d942477..10e516708d 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/Agent.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/Agent.cs @@ -50,7 +50,7 @@ public abstract class Agent /// /// Run the agent with no message assuming that all required instructions are already provided to the agent or on the thread. /// - /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent reponse. + /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent response. /// Optional parameters for agent invocation. /// The to monitor for cancellation requests. The default is . /// A containing the list of items. @@ -66,7 +66,7 @@ public abstract class Agent /// Run the agent with the provided message and arguments. /// /// The message to pass to the agent. - /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent reponse. + /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent response. /// Optional parameters for agent invocation. /// The to monitor for cancellation requests. The default is . /// A containing the list of items. @@ -88,7 +88,7 @@ public abstract class Agent /// Run the agent with the provided message and arguments. /// /// The message to pass to the agent. - /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent reponse. + /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent response. /// Optional parameters for agent invocation. /// The to monitor for cancellation requests. The default is . /// A containing the list of items. @@ -107,7 +107,7 @@ public abstract class Agent /// Run the agent with the provided message and arguments. /// /// The messages to pass to the agent. - /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent reponse. + /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent response. /// Optional parameters for agent invocation. /// The to monitor for cancellation requests. The default is . /// A containing the list of items. @@ -120,7 +120,7 @@ public abstract class Agent /// /// Run the agent with no message assuming that all required instructions are already provided to the agent or on the thread. /// - /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent reponse. + /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent response. /// Optional parameters for agent invocation. /// The to monitor for cancellation requests. The default is . /// An async list of response items that each contain a . @@ -136,7 +136,7 @@ public abstract class Agent /// Run the agent with the provided message and arguments. /// /// The message to pass to the agent. - /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent reponse. + /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent response. /// Optional parameters for agent invocation. /// The to monitor for cancellation requests. The default is . /// An async list of response items that each contain a . @@ -158,7 +158,7 @@ public abstract class Agent /// Run the agent with the provided message and arguments. /// /// The message to pass to the agent. - /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent reponse. + /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent response. /// Optional parameters for agent invocation. /// The to monitor for cancellation requests. The default is . /// An async list of response items that each contain a . @@ -177,7 +177,7 @@ public abstract class Agent /// Run the agent with the provided message and arguments. /// /// The messages to pass to the agent. - /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent reponse. + /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent response. /// Optional parameters for agent invocation. /// The to monitor for cancellation requests. The default is . /// An async list of response items that each contain a . diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/ActivityProcessor.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/ActivityProcessor.cs new file mode 100644 index 0000000000..7b10f52d4e --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/ActivityProcessor.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Agents.Core.Models; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.AI.Agents.CopilotStudio; + +/// +/// Contains code to process responses from the Copilot Studio agent and convert them to objects. +/// +internal static class ActivityProcessor +{ + public static async IAsyncEnumerable<(ChatMessage message, bool reasoning)> ProcessActivityAsync(IAsyncEnumerable activities, bool streaming, ILogger logger) + { + await foreach (IActivity activity in activities.ConfigureAwait(false)) + { + switch (activity.Type) + { + case "message": + // For streaming scenarios, we sometimes receive intermediate text via "typing" activities, but not always. + // In some cases the response is also returned multiple times via "typing" activities, so the only reliable + // way to get the final response is to wait for a "message" activity. + + // TODO: Prototype a custom AIContent type for CardActions, where the user is instructed to + // pick from a list of actions. + // The activity text doesn't make sense without the actions, as the message + // is often instructing the user to pick from the provided list of actions. + yield return (CreateChatMessageFromActivity(activity, [new TextContent(activity.Text)]), false); + break; + case "typing": + case "event": + // TODO: Revisit usage of TextReasoningContent here, to evaluate whether all are really reasoning + // or whether simply an AIContent base type would be more appropriate. + yield return (CreateChatMessageFromActivity(activity, [new TextReasoningContent(activity.Text)]), true); + break; + default: + logger.LogWarning("Unknown activity type '{ActivityType}' received.", activity.Type); + break; + } + } + } + + private static ChatMessage CreateChatMessageFromActivity(IActivity activity, IEnumerable messageContent) + { + return new ChatMessage(ChatRole.Assistant, [.. messageContent]) + { + AuthorName = activity.From?.Name, + MessageId = activity.Id, + RawRepresentation = activity + }; + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/CopilotStudioAgent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/CopilotStudioAgent.cs new file mode 100644 index 0000000000..3c605224a1 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/CopilotStudioAgent.cs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.CopilotStudio.Client; +using Microsoft.Agents.Core.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI.Agents.CopilotStudio; + +/// +/// Represents a Copilot Studio agent in the cloud. +/// +public class CopilotStudioAgent : Agent +{ + private readonly ILogger _logger; + + /// + /// The client used to interact with the Copilot Agent service. + /// + public CopilotClient Client { get; } + + /// + /// Initializes a new instance of the class. + /// + /// A client used to interact with the Copilot Agent service. + /// Optional logger factory to use for logging. + public CopilotStudioAgent(CopilotClient client, ILoggerFactory? loggerFactory = null) + { + this.Client = client; + + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + } + + /// + public override AgentThread GetNewThread() + { + return new CopilotStudioAgentThread(); + } + + /// + public override async Task RunAsync( + IReadOnlyCollection messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + Throw.IfNull(messages); + + // Ensure that we have a valid thread to work with. + CopilotStudioAgentThread copilotStudioAgentThread = base.ValidateOrCreateThreadType(thread, () => new CopilotStudioAgentThread()); + if (copilotStudioAgentThread.Id is null) + { + // If the thread ID is null, we need to start a new conversation and set the thread ID accordingly. + copilotStudioAgentThread.Id = await this.StartNewConversationAsync(cancellationToken).ConfigureAwait(false); + } + + // Invoke the Copilot Studio agent with the provided messages. + string question = string.Join("\n", messages.Select(m => m.Text)); + var responseMessages = ActivityProcessor.ProcessActivityAsync(this.Client.AskQuestionAsync(question, copilotStudioAgentThread.Id, cancellationToken), streaming: false, this._logger); + + // Enumerate the response messages + var responseMessagesList = new List(); + await foreach ((ChatMessage message, bool reasoning) in responseMessages.ConfigureAwait(false)) + { + // If the message is a reasoning message, return it as part of the intermediate messages + // instead of the final response. + if (reasoning) + { + if (options?.OnIntermediateMessages is not null) + { + await options.OnIntermediateMessages.Invoke([message]).ConfigureAwait(false); + } + + continue; + } + + // Add the message to the list + responseMessagesList.Add(message); + } + + // TODO: Review list of ChatResponse properties to ensure we set all availble values. + // Setting ResponseId and MessageId end up being particularly important for streaming consumers + // so that they can tell things like response boundaries. + return new ChatResponse(responseMessagesList) + { + ResponseId = responseMessagesList.LastOrDefault()?.MessageId, + ConversationId = copilotStudioAgentThread.Id, + }; + } + + /// + public override async IAsyncEnumerable RunStreamingAsync( + IReadOnlyCollection messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Throw.IfNull(messages); + + // Ensure that we have a valid thread to work with. + CopilotStudioAgentThread copilotStudioAgentThread = base.ValidateOrCreateThreadType(thread, () => new CopilotStudioAgentThread()); + if (copilotStudioAgentThread.Id is null) + { + // If the thread ID is null, we need to start a new conversation and set the thread ID accordingly. + copilotStudioAgentThread.Id = await this.StartNewConversationAsync(cancellationToken).ConfigureAwait(false); + } + + // Invoke the Copilot Studio agent with the provided messages. + string question = string.Join("\n", messages.Select(m => m.Text)); + var responseMessages = ActivityProcessor.ProcessActivityAsync(this.Client.AskQuestionAsync(question, copilotStudioAgentThread.Id, cancellationToken), streaming: true, this._logger); + + // Enumerate the response messages + await foreach ((ChatMessage message, bool reasoning) in responseMessages.ConfigureAwait(false)) + { + // If the message is a reasoning message, return it as part of the intermediate messages. + if (reasoning && options?.OnIntermediateMessages is not null) + { + await options.OnIntermediateMessages.Invoke([message]).ConfigureAwait(false); + } + + // TODO: Review list of ChatResponse properties to ensure we set all availble values. + // Setting ResponseId and MessageId end up being particularly important for streaming consumers + // so that they can tell things like response boundaries. + yield return new ChatResponseUpdate(message.Role, message.Contents) + { + AdditionalProperties = message.AdditionalProperties, + AuthorName = message.AuthorName, + RawRepresentation = message.RawRepresentation, + ResponseId = message.MessageId, + MessageId = message.MessageId, + ConversationId = copilotStudioAgentThread.Id, + }; + } + } + + private async Task StartNewConversationAsync(CancellationToken cancellationToken) + { + string? conversationId = null; + await foreach (IActivity activity in this.Client.StartConversationAsync(emitStartConversationEvent: true, cancellationToken).ConfigureAwait(false)) + { + if (activity.Conversation is not null) + { + conversationId = activity.Conversation.Id; + } + } + + if (string.IsNullOrEmpty(conversationId)) + { + throw new System.InvalidOperationException("Failed to start a new conversation."); + } + + return conversationId!; + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/CopilotStudioAgentThread.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/CopilotStudioAgentThread.cs new file mode 100644 index 0000000000..b7493a1f7b --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/CopilotStudioAgentThread.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Extensions.AI.Agents.CopilotStudio; + +/// +/// Represents a thread for interacting with a Copilot Studio agent. +/// +public class CopilotStudioAgentThread : AgentThread +{ +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/Microsoft.Extensions.AI.Agents.CopilotStudio.csproj b/dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/Microsoft.Extensions.AI.Agents.CopilotStudio.csproj new file mode 100644 index 0000000000..333d7ed3c9 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/Microsoft.Extensions.AI.Agents.CopilotStudio.csproj @@ -0,0 +1,30 @@ + + + + $(ProjectsTargetFrameworks) + $(ProjectsDebugTargetFrameworks) + alpha + + + + true + true + + + + + + + + + + + + + + + Microsoft Agent Framework: Copilot Studio Agent + Contains the Microsoft Agent Framework Copilot Studio Agent. + + + diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentExtensions.cs index 69c5f2aaaf..4c1691540f 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentExtensions.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentExtensions.cs @@ -17,7 +17,7 @@ public static class ChatClientAgentExtensions /// /// Target agent to run. /// The messages to pass to the agent. - /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent reponse. + /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent response. /// Optional parameters for agent invocation. /// Optional chat options. /// The to monitor for cancellation requests. The default is . @@ -41,7 +41,7 @@ public static class ChatClientAgentExtensions /// /// Target agent to run. /// The prompt to pass to the agent. - /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent reponse. + /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent response. /// Optional parameters for agent invocation. /// Optional chat options. /// The to monitor for cancellation requests. The default is . @@ -65,7 +65,7 @@ public static class ChatClientAgentExtensions /// /// Target agent to run. /// The messages to pass to the agent. - /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent reponse. + /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent response. /// Optional parameters for agent invocation. /// Optional chat options. /// The to monitor for cancellation requests. The default is . @@ -88,7 +88,7 @@ public static class ChatClientAgentExtensions /// /// Target agent to run. /// The prompt to pass to the agent. - /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent reponse. + /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent response. /// Optional parameters for agent invocation. /// Optional chat options. /// The to monitor for cancellation requests. The default is . diff --git a/dotnet/tests/AgentConformance.IntegrationTests/RunStreamingTests.cs b/dotnet/tests/AgentConformance.IntegrationTests/RunStreamingTests.cs index 7f99594a53..4f922e4bb4 100644 --- a/dotnet/tests/AgentConformance.IntegrationTests/RunStreamingTests.cs +++ b/dotnet/tests/AgentConformance.IntegrationTests/RunStreamingTests.cs @@ -16,6 +16,18 @@ namespace AgentConformance.IntegrationTests; public abstract class RunStreamingTests(Func createAgentFixture) : AgentTests(createAgentFixture) where TAgentFixture : IAgentFixture { + [RetryFact(Constants.RetryCount, Constants.RetryDelay)] + public virtual async Task RunWithNoMessageDoesNotFailAsync() + { + // Arrange + var agent = this.Fixture.Agent; + var thread = agent.GetNewThread(); + await using var cleanup = new ThreadCleanup(thread, this.Fixture); + + // Act + var chatResponses = await agent.RunStreamingAsync(thread).ToListAsync(); + } + [RetryFact(Constants.RetryCount, Constants.RetryDelay)] public virtual async Task RunWithStringReturnsExpectedResultAsync() { diff --git a/dotnet/tests/AgentConformance.IntegrationTests/RunTests.cs b/dotnet/tests/AgentConformance.IntegrationTests/RunTests.cs index 81d6aec3ae..f8e4915742 100644 --- a/dotnet/tests/AgentConformance.IntegrationTests/RunTests.cs +++ b/dotnet/tests/AgentConformance.IntegrationTests/RunTests.cs @@ -16,6 +16,21 @@ namespace AgentConformance.IntegrationTests; public abstract class RunTests(Func createAgentFixture) : AgentTests(createAgentFixture) where TAgentFixture : IAgentFixture { + [RetryFact(Constants.RetryCount, Constants.RetryDelay)] + public virtual async Task RunWithNoMessageDoesNotFailAsync() + { + // Arrange + var agent = this.Fixture.Agent; + var thread = agent.GetNewThread(); + await using var cleanup = new ThreadCleanup(thread, this.Fixture); + + // Act + var chatResponse = await agent.RunAsync(thread); + + // Assert + Assert.NotNull(chatResponse); + } + [RetryFact(Constants.RetryCount, Constants.RetryDelay)] public virtual async Task RunWithStringReturnsExpectedResultAsync() { diff --git a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj index 02991f5a93..9a0f2f56bf 100644 --- a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj +++ b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj @@ -11,4 +11,8 @@ + + + + diff --git a/dotnet/tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj b/dotnet/tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj new file mode 100644 index 0000000000..7a7cc67804 --- /dev/null +++ b/dotnet/tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj @@ -0,0 +1,19 @@ + + + + $(ProjectsTargetFrameworks) + $(ProjectsDebugTargetFrameworks) + True + true + + + + + + + + + + + + diff --git a/dotnet/tests/CopilotStudio.IntegrationTests/CopilotStudioFixture.cs b/dotnet/tests/CopilotStudio.IntegrationTests/CopilotStudioFixture.cs new file mode 100644 index 0000000000..83be84b8ee --- /dev/null +++ b/dotnet/tests/CopilotStudio.IntegrationTests/CopilotStudioFixture.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; +using AgentConformance.IntegrationTests.Support; +using CopilotStudio.IntegrationTests.Support; +using Microsoft.Agents.CopilotStudio.Client; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; +using Microsoft.Extensions.AI.Agents.CopilotStudio; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace CopilotStudio.IntegrationTests; + +public class CopilotStudioFixture : IAgentFixture +{ +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + private Agent _agent; +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + + public Agent Agent => this._agent; + + public Task> GetChatHistoryAsync(AgentThread thread) + { + throw new NotSupportedException("CopilotStudio doesn't allow retrieval of chat history."); + } + + public Task DeleteThreadAsync(AgentThread thread) + { + // Chat Completion does not require/support deleting threads, so this is a no-op. + return Task.CompletedTask; + } + + public Task InitializeAsync() + { + const string CopilotStudioHttpClientName = nameof(CopilotStudioAgent); + + var config = TestConfiguration.LoadSection(); + var settings = new CopilotStudioConnectionSettings(config.TenantId, config.AppClientId) + { + DirectConnectUrl = config.DirectConnectUrl, + }; + + ServiceCollection services = new(); + + services + .AddSingleton(settings) + .AddSingleton() + .AddHttpClient(CopilotStudioHttpClientName) + .ConfigurePrimaryHttpMessageHandler(); + + IHttpClientFactory httpClientFactory = + services + .BuildServiceProvider() + .GetRequiredService(); + + CopilotClient client = new(settings, httpClientFactory, NullLogger.Instance, CopilotStudioHttpClientName); + + this._agent = new CopilotStudioAgent(client); + + return Task.CompletedTask; + } + + public Task DisposeAsync() + { + return Task.CompletedTask; + } +} diff --git a/dotnet/tests/CopilotStudio.IntegrationTests/CopilotStudioRunStreamingTests.cs b/dotnet/tests/CopilotStudio.IntegrationTests/CopilotStudioRunStreamingTests.cs new file mode 100644 index 0000000000..ee4febbcdf --- /dev/null +++ b/dotnet/tests/CopilotStudio.IntegrationTests/CopilotStudioRunStreamingTests.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; + +namespace CopilotStudio.IntegrationTests; + +public class CopilotStudioRunStreamingTests() : RunStreamingTests(() => new()) +{ + [Fact(Skip = "Copilot Studio does not support thread history retrieval, so this test is not applicable.")] + public override Task ThreadMaintainsHistoryAsync() + { + return Task.CompletedTask; + } +} diff --git a/dotnet/tests/CopilotStudio.IntegrationTests/CopilotStudioRunTests.cs b/dotnet/tests/CopilotStudio.IntegrationTests/CopilotStudioRunTests.cs new file mode 100644 index 0000000000..95cb6c6db0 --- /dev/null +++ b/dotnet/tests/CopilotStudio.IntegrationTests/CopilotStudioRunTests.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; + +namespace CopilotStudio.IntegrationTests; + +public class CopilotStudioRunTests() : RunTests(() => new()) +{ + [Fact(Skip = "Copilot Studio does not support thread history retrieval, so this test is not applicable.")] + public override Task ThreadMaintainsHistoryAsync() + { + return Task.CompletedTask; + } +} diff --git a/dotnet/tests/CopilotStudio.IntegrationTests/Support/CopilotStudioAgentConfiguration.cs b/dotnet/tests/CopilotStudio.IntegrationTests/Support/CopilotStudioAgentConfiguration.cs new file mode 100644 index 0000000000..670ed5d706 --- /dev/null +++ b/dotnet/tests/CopilotStudio.IntegrationTests/Support/CopilotStudioAgentConfiguration.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace CopilotStudio.IntegrationTests.Support; + +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. +#pragma warning disable CA1812 // Internal class that is apparently never instantiated. + +internal sealed class CopilotStudioAgentConfiguration +{ + public string DirectConnectUrl { get; set; } + + public string TenantId { get; set; } + + public string AppClientId { get; set; } +} diff --git a/dotnet/tests/CopilotStudio.IntegrationTests/Support/CopilotStudioConnectionSettings.cs b/dotnet/tests/CopilotStudio.IntegrationTests/Support/CopilotStudioConnectionSettings.cs new file mode 100644 index 0000000000..26970b3776 --- /dev/null +++ b/dotnet/tests/CopilotStudio.IntegrationTests/Support/CopilotStudioConnectionSettings.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Agents.CopilotStudio.Client; +using Microsoft.Agents.CopilotStudio.Client.Discovery; +using Microsoft.Extensions.Configuration; + +namespace CopilotStudio.IntegrationTests.Support; + +/// +/// with additional properties to specify Application (Client) Id, +/// Tenant Id, and optionally the Application Client secret. +/// +internal sealed class CopilotStudioConnectionSettings : ConnectionSettings +{ + /// + /// Application ID for creating the authentication for the connection + /// + public string AppClientId { get; } + + /// + /// Application secret for creating the authentication for the connection + /// + public string? AppClientSecret { get; } + + /// + /// Tenant ID for creating the authentication for the connection + /// + public string TenantId { get; } + + /// + /// Use interactive or service connection for authentication. + /// Defaults to true, meaning interactive authentication will be used. + /// + public bool UseInteractiveAuthentication { get; set; } = true; + + /// + /// Instantiate a new instance of the from provided settings. + /// + public CopilotStudioConnectionSettings(string tenantId, string appClientId, string? appClientSecret = null) + { + this.TenantId = tenantId; + this.AppClientId = appClientId; + this.AppClientSecret = appClientSecret; + this.Cloud = PowerPlatformCloud.Prod; + this.CopilotAgentType = AgentType.Published; + } + + /// + /// Instantiate a new instance of the from a configuration section. + /// + /// + /// + public CopilotStudioConnectionSettings(IConfigurationSection config) + : base(config) + { + this.AppClientId = config[nameof(this.AppClientId)] ?? throw new ArgumentException($"{nameof(this.AppClientId)} not found in config"); + this.TenantId = config[nameof(this.TenantId)] ?? throw new ArgumentException($"{nameof(this.TenantId)} not found in config"); + this.AppClientSecret = config[nameof(this.AppClientSecret)]; + } +} diff --git a/dotnet/tests/CopilotStudio.IntegrationTests/Support/CopilotStudioTokenHandler.cs b/dotnet/tests/CopilotStudio.IntegrationTests/Support/CopilotStudioTokenHandler.cs new file mode 100644 index 0000000000..c4e4539524 --- /dev/null +++ b/dotnet/tests/CopilotStudio.IntegrationTests/Support/CopilotStudioTokenHandler.cs @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.CopilotStudio.Client; +using Microsoft.Identity.Client; +using Microsoft.Identity.Client.Extensions.Msal; +using Microsoft.Shared.Diagnostics; + +namespace CopilotStudio.IntegrationTests.Support; + +#pragma warning disable CA1812 // Internal class that is apparently never instantiated. + +/// +/// A that adds an authentication token to the request headers for Copilot Studio API calls. +/// +/// +/// For more information on how to setup various authentication flows, see the Microsoft Identity documentation at https://aka.ms/msal. +/// +internal sealed class CopilotStudioTokenHandler : HttpClientHandler +{ + private const string AuthenticationHeader = "Bearer"; + private const string CacheFolderName = "mcs_client_console"; + private const string KeyChainServiceName = "copilot_studio_client_app"; + private const string KeyChainAccountName = "copilot_studio_client"; + + private readonly CopilotStudioConnectionSettings _settings; + private readonly string[] _scopes; + + private IConfidentialClientApplication? _clientApplication; + + /// + /// Initializes a new instance of the class with the specified connection settings. + /// + /// The connection settings for Copilot Studio. + public CopilotStudioTokenHandler(CopilotStudioConnectionSettings settings) + { + Throw.IfNull(settings); + + this._settings = settings; + this._scopes = [CopilotClient.ScopeFromSettings(this._settings)]; + } + + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.Headers.Authorization is null) + { + AuthenticationResult authResponse = await this.AuthenticateAsync(cancellationToken).ConfigureAwait(false); + + request.Headers.Authorization = new AuthenticationHeaderValue(AuthenticationHeader, authResponse.AccessToken); + } + + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + + private Task AuthenticateAsync(CancellationToken cancellationToken) => + this._settings.UseInteractiveAuthentication ? + this.AuthenticateInteractiveAsync(cancellationToken) : + this.AuthenticateServiceAsync(cancellationToken); + + private async Task AuthenticateServiceAsync(CancellationToken cancellationToken) + { + if (this._clientApplication is null) + { + this._clientApplication = ConfidentialClientApplicationBuilder.Create(this._settings.AppClientId) + .WithAuthority(AzureCloudInstance.AzurePublic, this._settings.TenantId) + .WithClientSecret(this._settings.AppClientSecret) + .Build(); + + MsalCacheHelper tokenCacheHelper = await CreateCacheHelperAsync("AppTokenCache").ConfigureAwait(false); + tokenCacheHelper.RegisterCache(this._clientApplication.AppTokenCache); + } + + AuthenticationResult authResponse; + + authResponse = await this._clientApplication.AcquireTokenForClient(this._scopes).ExecuteAsync(cancellationToken).ConfigureAwait(false); + + return authResponse; + } + + private async Task AuthenticateInteractiveAsync(CancellationToken cancellationToken = default!) + { + IPublicClientApplication app = + PublicClientApplicationBuilder.Create(this._settings.AppClientId) + .WithAuthority(AadAuthorityAudience.AzureAdMyOrg) + .WithTenantId(this._settings.TenantId) + .WithRedirectUri("http://localhost") + .Build(); + + MsalCacheHelper tokenCacheHelper = await CreateCacheHelperAsync("TokenCache").ConfigureAwait(false); + tokenCacheHelper.RegisterCache(app.UserTokenCache); + + IEnumerable accounts = await app.GetAccountsAsync().ConfigureAwait(false); + IAccount? account = accounts.FirstOrDefault(); + + AuthenticationResult authResponse; + + try + { + authResponse = await app.AcquireTokenSilent(this._scopes, account).ExecuteAsync(cancellationToken).ConfigureAwait(false); + } + catch (MsalUiRequiredException) + { + authResponse = await app.AcquireTokenInteractive(this._scopes).ExecuteAsync(cancellationToken).ConfigureAwait(false); + } + + return authResponse; + } + + private static async Task CreateCacheHelperAsync(string cacheFileName) + { + string currentDir = Path.Combine(AppContext.BaseDirectory, CacheFolderName); + + if (!Directory.Exists(currentDir)) + { + Directory.CreateDirectory(currentDir); + } + + StorageCreationPropertiesBuilder storageProperties = new(cacheFileName, currentDir); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + storageProperties.WithLinuxUnprotectedFile(); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + storageProperties.WithMacKeyChain(KeyChainServiceName, KeyChainAccountName); + } + + MsalCacheHelper tokenCacheHelper = await MsalCacheHelper.CreateAsync(storageProperties.Build()).ConfigureAwait(false); + + return tokenCacheHelper; + } +}