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 920a221516..0000000000 Binary files a/.vs/af1/v17/.wsuo and /dev/null differ 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; + } +}