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.
This commit is contained in:
westey
2025-07-11 10:20:40 +01:00
committed by GitHub
Unverified
parent b85c17c935
commit b708e2507d
22 changed files with 658 additions and 57 deletions
+4 -1
View File
@@ -173,4 +173,7 @@ cython_debug/
# PyPI configuration file
.pypirc
.DS_Store
.DS_Store
# Visual Studio 2015/2017 cache/options directory
.vs/
-7
View File
@@ -1,7 +0,0 @@
{
"ExpandedNodes": [
""
],
"SelectedNode": "\\agent-framework-dotnet.slnx",
"PreviewInSolutionExplorer": false
}
BIN
View File
Binary file not shown.
-27
View File
@@ -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}"
}
]
}
]
}
]
}
+14 -10
View File
@@ -6,30 +6,34 @@
</PropertyGroup>
<ItemGroup>
<!-- Azure.* -->
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.1.0-beta.2" />
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.1.0-beta.3" />
<PackageVersion Include="Azure.AI.OpenAI" Version="2.2.0-beta.4" />
<PackageVersion Include="Azure.Identity" Version="1.14.0" />
<!-- System.* -->
<PackageVersion Include="System.Linq.Async" Version="6.0.1" />
<PackageVersion Include="System.Text.Json" Version="8.0.6" />
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="9.0.7" />
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
<!-- Microsoft.Extensions.* -->
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
<PackageVersion Include="Microsoft.Extensions.AI" Version="9.7.0" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="9.7.0-preview.1.25356.2" />
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="9.7.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="9.0.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="9.0.7" />
<PackageVersion Include="System.Linq.Async" Version="6.0.1" />
<PackageVersion Include="System.Text.Json" Version="8.0.6" />
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="9.0.7" />
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
<!-- Microsoft.Extensions.* -->
<PackageVersion Include="Microsoft.Extensions.AI" Version="9.7.0" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="9.7.0-preview.1.25356.2" />
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="9.7.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.7" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.7" />
<!-- Agent SDKs -->
<PackageVersion Include="Microsoft.Agents.CopilotStudio.Client" Version="1.1.125-beta" />
<!-- Identity -->
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.67.2" />
<!-- Test -->
<PackageVersion Include="FluentAssertions" Version="8.2.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
+4
View File
@@ -11,6 +11,9 @@
<Project Path="tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj" Id="e91d4745-7ccd-4de8-9bc0-31081f540038">
<BuildType Solution="Publish|*" Project="Release" />
</Project>
<Project Path="tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj">
<BuildType Solution="Publish|*" Project="Release" />
</Project>
<Project Path="tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj" Id="a35b6971-6f27-4904-a168-8e12b229cced">
<BuildType Solution="Publish|*" Project="Release" />
</Project>
@@ -114,6 +117,7 @@
<Project Path="src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.csproj" />
<Project Path="src/Microsoft.Extensions.AI.Agents.Runtime.InProcess/Microsoft.Extensions.AI.Agents.Runtime.InProcess.csproj" />
<Project Path="src/Microsoft.Extensions.AI.Agents/Microsoft.Extensions.AI.Agents.csproj" />
<Project Path="src/Microsoft.Extensions.AI.Agents.CopilotStudio/Microsoft.Extensions.AI.Agents.CopilotStudio.csproj" />
</Folder>
<Folder Name="/UnitTests/">
<Project Path="tests/Microsoft.Agents.Orchestration.UnitTests/Microsoft.Agents.Orchestration.UnitTests.csproj">
@@ -50,7 +50,7 @@ public abstract class Agent
/// <summary>
/// Run the agent with no message assuming that all required instructions are already provided to the agent or on the thread.
/// </summary>
/// <param name="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.</param>
/// <param name="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 response.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatResponse"/> containing the list of <see cref="ChatMessage"/> items.</returns>
@@ -66,7 +66,7 @@ public abstract class Agent
/// Run the agent with the provided message and arguments.
/// </summary>
/// <param name="message">The message to pass to the agent.</param>
/// <param name="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.</param>
/// <param name="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 response.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatResponse"/> containing the list of <see cref="ChatMessage"/> items.</returns>
@@ -88,7 +88,7 @@ public abstract class Agent
/// Run the agent with the provided message and arguments.
/// </summary>
/// <param name="message">The message to pass to the agent.</param>
/// <param name="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.</param>
/// <param name="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 response.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatResponse"/> containing the list of <see cref="ChatMessage"/> items.</returns>
@@ -107,7 +107,7 @@ public abstract class Agent
/// Run the agent with the provided message and arguments.
/// </summary>
/// <param name="messages">The messages to pass to the agent.</param>
/// <param name="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.</param>
/// <param name="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 response.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatResponse"/> containing the list of <see cref="ChatMessage"/> items.</returns>
@@ -120,7 +120,7 @@ public abstract class Agent
/// <summary>
/// Run the agent with no message assuming that all required instructions are already provided to the agent or on the thread.
/// </summary>
/// <param name="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.</param>
/// <param name="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 response.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An async list of response items that each contain a <see cref="ChatResponseUpdate"/>.</returns>
@@ -136,7 +136,7 @@ public abstract class Agent
/// Run the agent with the provided message and arguments.
/// </summary>
/// <param name="message">The message to pass to the agent.</param>
/// <param name="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.</param>
/// <param name="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 response.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An async list of response items that each contain a <see cref="ChatResponseUpdate"/>.</returns>
@@ -158,7 +158,7 @@ public abstract class Agent
/// Run the agent with the provided message and arguments.
/// </summary>
/// <param name="message">The message to pass to the agent.</param>
/// <param name="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.</param>
/// <param name="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 response.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An async list of response items that each contain a <see cref="ChatResponseUpdate"/>.</returns>
@@ -177,7 +177,7 @@ public abstract class Agent
/// Run the agent with the provided message and arguments.
/// </summary>
/// <param name="messages">The messages to pass to the agent.</param>
/// <param name="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.</param>
/// <param name="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 response.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An async list of response items that each contain a <see cref="ChatResponseUpdate"/>.</returns>
@@ -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;
/// <summary>
/// Contains code to process <see cref="IActivity"/> responses from the Copilot Studio agent and convert them to <see cref="ChatMessage"/> objects.
/// </summary>
internal static class ActivityProcessor
{
public static async IAsyncEnumerable<(ChatMessage message, bool reasoning)> ProcessActivityAsync(IAsyncEnumerable<IActivity> 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<AIContent> messageContent)
{
return new ChatMessage(ChatRole.Assistant, [.. messageContent])
{
AuthorName = activity.From?.Name,
MessageId = activity.Id,
RawRepresentation = activity
};
}
}
@@ -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;
/// <summary>
/// Represents a Copilot Studio agent in the cloud.
/// </summary>
public class CopilotStudioAgent : Agent
{
private readonly ILogger _logger;
/// <summary>
/// The client used to interact with the Copilot Agent service.
/// </summary>
public CopilotClient Client { get; }
/// <summary>
/// Initializes a new instance of the <see cref="CopilotStudioAgent"/> class.
/// </summary>
/// <param name="client">A client used to interact with the Copilot Agent service.</param>
/// <param name="loggerFactory">Optional logger factory to use for logging.</param>
public CopilotStudioAgent(CopilotClient client, ILoggerFactory? loggerFactory = null)
{
this.Client = client;
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<CopilotStudioAgent>();
}
/// <inheritdoc/>
public override AgentThread GetNewThread()
{
return new CopilotStudioAgentThread();
}
/// <inheritdoc/>
public override async Task<ChatResponse> RunAsync(
IReadOnlyCollection<ChatMessage> 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<ChatMessage>();
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,
};
}
/// <inheritdoc/>
public override async IAsyncEnumerable<ChatResponseUpdate> RunStreamingAsync(
IReadOnlyCollection<ChatMessage> 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<string> 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!;
}
}
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Extensions.AI.Agents.CopilotStudio;
/// <summary>
/// Represents a thread for interacting with a Copilot Studio agent.
/// </summary>
public class CopilotStudioAgentThread : AgentThread
{
}
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<VersionSuffix>alpha</VersionSuffix>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectDiagnosticClassesOnLegacy>true</InjectDiagnosticClassesOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<ItemGroup>
<ProjectReference Include="..\Microsoft.Extensions.AI.Agents.Abstractions\Microsoft.Extensions.AI.Agents.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Agents.CopilotStudio.Client" />
</ItemGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework: Copilot Studio Agent</Title>
<Description>Contains the Microsoft Agent Framework Copilot Studio Agent.</Description>
</PropertyGroup>
</Project>
@@ -17,7 +17,7 @@ public static class ChatClientAgentExtensions
/// </summary>
/// <param name="agent">Target agent to run.</param>
/// <param name="messages">The messages to pass to the agent.</param>
/// <param name="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.</param>
/// <param name="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 response.</param>
/// <param name="agentRunOptions">Optional parameters for agent invocation.</param>
/// <param name="chatOptions">Optional chat options.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
@@ -41,7 +41,7 @@ public static class ChatClientAgentExtensions
/// </summary>
/// <param name="agent">Target agent to run.</param>
/// <param name="prompt">The prompt to pass to the agent.</param>
/// <param name="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.</param>
/// <param name="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 response.</param>
/// <param name="agentRunOptions">Optional parameters for agent invocation.</param>
/// <param name="chatOptions">Optional chat options.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
@@ -65,7 +65,7 @@ public static class ChatClientAgentExtensions
/// </summary>
/// <param name="agent">Target agent to run.</param>
/// <param name="messages">The messages to pass to the agent.</param>
/// <param name="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.</param>
/// <param name="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 response.</param>
/// <param name="agentRunOptions">Optional parameters for agent invocation.</param>
/// <param name="chatOptions">Optional chat options.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
@@ -88,7 +88,7 @@ public static class ChatClientAgentExtensions
/// </summary>
/// <param name="agent">Target agent to run.</param>
/// <param name="prompt">The prompt to pass to the agent.</param>
/// <param name="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.</param>
/// <param name="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 response.</param>
/// <param name="agentRunOptions">Optional parameters for agent invocation.</param>
/// <param name="chatOptions">Optional chat options.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
@@ -16,6 +16,18 @@ namespace AgentConformance.IntegrationTests;
public abstract class RunStreamingTests<TAgentFixture>(Func<TAgentFixture> createAgentFixture) : AgentTests<TAgentFixture>(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()
{
@@ -16,6 +16,21 @@ namespace AgentConformance.IntegrationTests;
public abstract class RunTests<TAgentFixture>(Func<TAgentFixture> createAgentFixture) : AgentTests<TAgentFixture>(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()
{
@@ -11,4 +11,8 @@
<ProjectReference Include="..\AgentConformance.IntegrationTests\AgentConformance.IntegrationTests.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
</ItemGroup>
</Project>
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
<InjectSharedThrow>true</InjectSharedThrow>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Extensions.AI.Agents.CopilotStudio\Microsoft.Extensions.AI.Agents.CopilotStudio.csproj" />
<ProjectReference Include="..\AgentConformance.IntegrationTests\AgentConformance.IntegrationTests.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" />
</ItemGroup>
</Project>
@@ -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<List<ChatMessage>> 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<CopilotStudioAgentConfiguration>();
var settings = new CopilotStudioConnectionSettings(config.TenantId, config.AppClientId)
{
DirectConnectUrl = config.DirectConnectUrl,
};
ServiceCollection services = new();
services
.AddSingleton(settings)
.AddSingleton<CopilotStudioTokenHandler>()
.AddHttpClient(CopilotStudioHttpClientName)
.ConfigurePrimaryHttpMessageHandler<CopilotStudioTokenHandler>();
IHttpClientFactory httpClientFactory =
services
.BuildServiceProvider()
.GetRequiredService<IHttpClientFactory>();
CopilotClient client = new(settings, httpClientFactory, NullLogger.Instance, CopilotStudioHttpClientName);
this._agent = new CopilotStudioAgent(client);
return Task.CompletedTask;
}
public Task DisposeAsync()
{
return Task.CompletedTask;
}
}
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
namespace CopilotStudio.IntegrationTests;
public class CopilotStudioRunStreamingTests() : RunStreamingTests<CopilotStudioFixture>(() => new())
{
[Fact(Skip = "Copilot Studio does not support thread history retrieval, so this test is not applicable.")]
public override Task ThreadMaintainsHistoryAsync()
{
return Task.CompletedTask;
}
}
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
namespace CopilotStudio.IntegrationTests;
public class CopilotStudioRunTests() : RunTests<CopilotStudioFixture>(() => new())
{
[Fact(Skip = "Copilot Studio does not support thread history retrieval, so this test is not applicable.")]
public override Task ThreadMaintainsHistoryAsync()
{
return Task.CompletedTask;
}
}
@@ -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; }
}
@@ -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;
/// <summary>
/// <see cref="ConnectionSettings"/> with additional properties to specify Application (Client) Id,
/// Tenant Id, and optionally the Application Client secret.
/// </summary>
internal sealed class CopilotStudioConnectionSettings : ConnectionSettings
{
/// <summary>
/// Application ID for creating the authentication for the connection
/// </summary>
public string AppClientId { get; }
/// <summary>
/// Application secret for creating the authentication for the connection
/// </summary>
public string? AppClientSecret { get; }
/// <summary>
/// Tenant ID for creating the authentication for the connection
/// </summary>
public string TenantId { get; }
/// <summary>
/// Use interactive or service connection for authentication.
/// Defaults to true, meaning interactive authentication will be used.
/// </summary>
public bool UseInteractiveAuthentication { get; set; } = true;
/// <summary>
/// Instantiate a new instance of the <see cref="CopilotStudioConnectionSettings"/> from provided settings.
/// </summary>
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;
}
/// <summary>
/// Instantiate a new instance of the <see cref="CopilotStudioConnectionSettings"/> from a configuration section.
/// </summary>
/// <param name="config"></param>
/// <exception cref="ArgumentException"></exception>
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)];
}
}
@@ -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.
/// <summary>
/// A <see cref="HttpClientHandler"/> that adds an authentication token to the request headers for Copilot Studio API calls.
/// </summary>
/// <remarks>
/// For more information on how to setup various authentication flows, see the Microsoft Identity documentation at https://aka.ms/msal.
/// </remarks>
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;
/// <summary>
/// Initializes a new instance of the <see cref="CopilotStudioTokenHandler"/> class with the specified connection settings.
/// </summary>
/// <param name="settings">The connection settings for Copilot Studio.</param>
public CopilotStudioTokenHandler(CopilotStudioConnectionSettings settings)
{
Throw.IfNull(settings);
this._settings = settings;
this._scopes = [CopilotClient.ScopeFromSettings(this._settings)];
}
/// <inheritdoc/>
protected override async Task<HttpResponseMessage> 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<AuthenticationResult> AuthenticateAsync(CancellationToken cancellationToken) =>
this._settings.UseInteractiveAuthentication ?
this.AuthenticateInteractiveAsync(cancellationToken) :
this.AuthenticateServiceAsync(cancellationToken);
private async Task<AuthenticationResult> 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<AuthenticationResult> 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<IAccount> 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<MsalCacheHelper> 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;
}
}