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;
+ }
+}