@@ -175,7 +275,7 @@
margin: 0;
}
- .agent-selection-card {
+ .agent-selection-card, .protocol-selection-card, .a2a-configuration-card {
background: white;
border-radius: 12px;
padding: 1.5rem;
@@ -183,20 +283,20 @@
margin-bottom: 2rem;
}
- .agent-select-label {
+ .agent-select-label, .protocol-select-label {
display: block;
font-weight: 600;
color: #374151;
margin-bottom: 0.75rem;
}
- .agent-select-wrapper {
+ .agent-select-wrapper, .protocol-select-wrapper {
display: flex;
gap: 1rem;
align-items: center;
}
- .agent-select {
+ .agent-select, .protocol-select {
flex: 1;
padding: 0.75rem 1rem;
font-size: 1rem;
@@ -208,21 +308,281 @@
transition: all 0.2s;
}
- .agent-select:hover:not(:disabled) {
+ .agent-select:hover:not(:disabled), .protocol-select:hover:not(:disabled) {
border-color: #6366f1;
}
- .agent-select:focus {
+ .agent-select:focus, .protocol-select:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
- .agent-select:disabled {
+ .agent-select:disabled, .protocol-select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
+ .protocol-info {
+ flex: 1;
+ min-width: 200px;
+ }
+
+ .protocol-description {
+ font-size: 0.875rem;
+ color: #6b7280;
+ font-style: italic;
+ }
+
+ /* A2A Configuration Card Styles */
+ .a2a-header {
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ padding: 0.5rem 0;
+ transition: all 0.2s;
+ }
+
+ .a2a-header:hover {
+ background: rgba(99, 102, 241, 0.05);
+ border-radius: 8px;
+ padding: 0.5rem;
+ margin: -0.5rem;
+ }
+
+ .a2a-title {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin: 0;
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: #374151;
+ }
+
+ .a2a-toggle-icon {
+ transition: transform 0.2s;
+ }
+
+ .a2a-toggle-icon.expanded {
+ transform: rotate(180deg);
+ }
+
+ .a2a-subtitle {
+ font-size: 0.875rem;
+ color: #6b7280;
+ margin-left: 1.25rem;
+ }
+
+ .a2a-content {
+ margin-top: 1rem;
+ padding-top: 1rem;
+ border-top: 1px solid #e5e7eb;
+ }
+
+ .discover-section {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+ }
+
+ .discover-btn {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1.5rem;
+ background: #059669;
+ color: white;
+ border: none;
+ border-radius: 8px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+ }
+
+ .discover-btn:hover:not(:disabled) {
+ background: #047857;
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(5, 150, 105, 0.3);
+ }
+
+ .discover-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ transform: none;
+ box-shadow: none;
+ }
+
+ .discover-info {
+ font-size: 0.875rem;
+ color: #6b7280;
+ }
+
+ .text-muted {
+ color: #9ca3af !important;
+ }
+
+ .spinner-small {
+ width: 16px;
+ height: 16px;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ border-top-color: white;
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+ }
+
+ /* Agent Card Display */
+ .agent-card-display {
+ background: #f9fafb;
+ border: 1px solid #e5e7eb;
+ border-radius: 8px;
+ padding: 1rem;
+ margin-top: 1rem;
+ }
+
+ .card-title {
+ margin: 0 0 1rem 0;
+ font-size: 1rem;
+ font-weight: 600;
+ color: #374151;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ }
+
+ .card-details {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ }
+
+ /* JSON Display Styles */
+ .json-container {
+ background: #1e293b;
+ border-radius: 8px;
+ overflow: hidden;
+ border: 1px solid #334155;
+ }
+
+ .json-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.75rem 1rem;
+ background: #334155;
+ border-bottom: 1px solid #475569;
+ }
+
+ .json-label {
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: #e2e8f0;
+ }
+
+ .copy-btn {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ padding: 0.375rem 0.75rem;
+ background: #475569;
+ color: #e2e8f0;
+ border: none;
+ border-radius: 4px;
+ font-size: 0.75rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+ }
+
+ .copy-btn:hover {
+ background: #64748b;
+ color: white;
+ }
+
+ .json-display {
+ margin: 0;
+ padding: 1rem;
+ background: #1e293b;
+ color: #e2e8f0;
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+ font-size: 0.875rem;
+ line-height: 1.5;
+ overflow-x: auto;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ }
+
+ .json-display code {
+ background: none;
+ color: inherit;
+ font-family: inherit;
+ padding: 0;
+ }
+
+ /* JSON Syntax Highlighting */
+ .json-display {
+ /* JSON strings */
+ --json-string: #a3d977;
+ /* JSON numbers */
+ --json-number: #ffc777;
+ /* JSON booleans */
+ --json-boolean: #ff966c;
+ /* JSON null */
+ --json-null: #c53030;
+ /* JSON keys */
+ --json-key: #82aaff;
+ /* JSON punctuation */
+ --json-punctuation: #c792ea;
+ }
+
+ .card-property {
+ display: flex;
+ gap: 0.75rem;
+ align-items: flex-start;
+ }
+
+ .card-property label {
+ font-weight: 600;
+ color: #374151;
+ min-width: 100px;
+ flex-shrink: 0;
+ }
+
+ .card-property span {
+ color: #6b7280;
+ flex: 1;
+ }
+
+ .capabilities-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ }
+
+ .capability-tag {
+ background: #dbeafe;
+ color: #1e40af;
+ padding: 0.25rem 0.5rem;
+ border-radius: 4px;
+ font-size: 0.75rem;
+ font-weight: 500;
+ }
+
+ /* Error Display */
+ .error-display {
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ color: #dc2626;
+ padding: 0.75rem;
+ border-radius: 8px;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-top: 1rem;
+ }
+
.start-chat-btn {
display: flex;
align-items: center;
@@ -494,190 +854,287 @@
.chat-container {
height: 500px;
}
+
+ .protocol-select-wrapper {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.5rem;
+ }
+
+ .protocol-info {
+ min-width: auto;
+ }
+
+ .discover-section {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.75rem;
+ }
+
+ .card-property {
+ flex-direction: column;
+ gap: 0.25rem;
+ }
+
+ .card-property label {
+ min-width: auto;
+ }
}
@code {
+ private string currentMessage = "";
+ private bool isStreaming = false;
+ private bool isLoadingAgents = true;
+ private string currentStreamedMessage = "";
+ private string selectedAgentName = "";
+ private List
availableAgents = new();
+ private List conversations = new();
+ private Conversation? currentConversation;
+ // protocol
+ private Protocol selectedProtocol;
+ // a2a agent card
+ private bool isA2AExpanded = false;
+ private bool isDiscoveringCard = false;
+ private string? discoveredAgentCardJson = null;
+ private string? discoveryError = null;
- private string currentMessage = "";
- private bool isStreaming = false;
- private bool isLoadingAgents = true;
- private string currentStreamedMessage = "";
- private string selectedAgentName = "";
- private List availableAgents = new();
- private List conversations = new();
- private Conversation? currentConversation;
+ private enum Protocol
+ {
+ AgenticFramework,
+ A2A // Agent-to-Agent protocol
+ }
- private class Conversation
- {
- public string SessionId { get; set; } = Guid.NewGuid().ToString();
- public string AgentName { get; set; } = "";
- public List Messages { get; set; } = new();
- }
+ private class Conversation
+ {
+ public string SessionId { get; set; } = Guid.NewGuid().ToString();
+ public string AgentName { get; set; } = "";
+ public List Messages { get; set; } = new();
+ }
- protected override async Task OnInitializedAsync()
- {
- Logger.LogDebug("Initializing Agent Chat component");
+ protected override async Task OnInitializedAsync()
+ {
+ Logger.LogDebug("Initializing Agent Chat component");
- // Load agents
- try
- {
- availableAgents = await AgentClient.GetAgentsAsync();
- Logger.LogInformation("Loaded {AgentCount} agents", availableAgents.Count);
+ // Load agents
+ try
+ {
+ availableAgents = await AgentClient.GetAgentsAsync();
+ Logger.LogInformation("Loaded {AgentCount} agents", availableAgents.Count);
+ Logger.LogInformation("Loaded Agents info: {AgentData}", JsonSerializer.Serialize(availableAgents, new JsonSerializerOptions() { WriteIndented = true }));
- // Default to first agent and start a conversation
- if (availableAgents.Any())
- {
- selectedAgentName = availableAgents.First().Name!;
- StartNewConversation();
- }
- }
- catch (Exception ex)
- {
- Logger.LogError(ex, "Failed to load agents");
- }
- finally
- {
- isLoadingAgents = false;
- }
+ // Default to first agent and start a conversation
+ if (availableAgents.Any())
+ {
+ selectedAgentName = availableAgents.First().Name!;
+ StartNewConversation();
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Failed to load agents");
+ }
+ finally
+ {
+ isLoadingAgents = false;
+ }
- // Conversations start fresh on page load
- }
+ // Conversations start fresh on page load
+ }
- private string GetAgentIcon(string agentName)
- {
- return agentName?.ToLower() switch
- {
- "pirate" => "🏴☠️",
- "knights-and-knaves" => "⚔️",
- _ => "🤖"
- };
- }
+ private string GetAgentIcon(string agentName)
+ {
+ return agentName?.ToLower() switch
+ {
+ "pirate" => "🏴☠️",
+ "knights-and-knaves" => "⚔️",
+ _ => "🤖"
+ };
+ }
- private string GetAgentDisplayName(string agentName)
- {
- return agentName?.ToLower() switch
- {
- "pirate" => "Pirate",
- "knights-and-knaves" => "Knights & Knaves",
- _ => agentName ?? "Agent"
- };
- }
+ private string GetAgentDisplayName(string agentName)
+ {
+ return agentName?.ToLower() switch
+ {
+ "pirate" => "Pirate",
+ "knights-and-knaves" => "Knights & Knaves",
+ _ => agentName ?? "Agent"
+ };
+ }
+ private void ToggleA2AExpanded()
+ {
+ isA2AExpanded = !isA2AExpanded;
+ }
+ private async Task DiscoverAgentCard()
+ {
+ if (string.IsNullOrEmpty(selectedAgentName) || isDiscoveringCard)
+ return;
- private void StartNewConversation()
- {
- if (string.IsNullOrEmpty(selectedAgentName))
- return;
+ isDiscoveringCard = true;
+ discoveryError = null;
+ discoveredAgentCardJson = null;
+ StateHasChanged();
- var newConversation = new Conversation
+ try
+ {
+ Logger.LogInformation("Discovering agent card for agent: {AgentName}", selectedAgentName);
+ var agentCard = await A2AActorClient.GetAgentCardAsync(selectedAgentName);
+ discoveredAgentCardJson = JsonSerializer.Serialize(agentCard, new JsonSerializerOptions() { WriteIndented = true });
+ Logger.LogInformation("Successfully discovered agent card for {AgentName}: {CardData}", selectedAgentName, discoveredAgentCardJson);
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Failed to discover agent card for {AgentName}", selectedAgentName);
+ discoveryError = $"Failed to discover agent card: {ex.Message}";
+ }
+ finally
+ {
+ isDiscoveringCard = false;
+ StateHasChanged();
+ }
+ }
+
+ private void StartNewConversation()
+ {
+ if (string.IsNullOrEmpty(selectedAgentName))
+ return;
+
+ var newConversation = new Conversation
{
AgentName = selectedAgentName
};
- conversations.Add(newConversation);
- currentConversation = newConversation;
+ conversations.Add(newConversation);
+ currentConversation = newConversation;
- Logger.LogInformation("Started new conversation with agent: {AgentName}, session: {SessionId}",
- newConversation.AgentName, newConversation.SessionId);
+ Logger.LogInformation("Started new conversation with agent: {AgentName}, session: {SessionId}",
+ newConversation.AgentName, newConversation.SessionId);
- StateHasChanged();
- }
+ StateHasChanged();
+ }
- private void SelectConversation(string sessionId)
- {
- currentConversation = conversations.FirstOrDefault(c => c.SessionId == sessionId);
- if (currentConversation != null)
- {
- selectedAgentName = currentConversation.AgentName;
- Logger.LogDebug("Selected conversation with session: {SessionId}", sessionId);
- }
- StateHasChanged();
- }
+ private void SelectConversation(string sessionId)
+ {
+ currentConversation = conversations.FirstOrDefault(c => c.SessionId == sessionId);
+ if (currentConversation != null)
+ {
+ selectedAgentName = currentConversation.AgentName;
+ Logger.LogDebug("Selected conversation with session: {SessionId}", sessionId);
+ }
+ StateHasChanged();
+ }
- private void CloseConversation(string sessionId)
- {
- var conversationToRemove = conversations.FirstOrDefault(c => c.SessionId == sessionId);
- if (conversationToRemove != null)
- {
- conversations.Remove(conversationToRemove);
+ private void CloseConversation(string sessionId)
+ {
+ var conversationToRemove = conversations.FirstOrDefault(c => c.SessionId == sessionId);
+ if (conversationToRemove != null)
+ {
+ conversations.Remove(conversationToRemove);
- if (currentConversation?.SessionId == sessionId)
- {
- currentConversation = conversations.FirstOrDefault();
- if (currentConversation != null)
- {
- selectedAgentName = currentConversation.AgentName;
- }
- }
+ if (currentConversation?.SessionId == sessionId)
+ {
+ currentConversation = conversations.FirstOrDefault();
+ if (currentConversation != null)
+ {
+ selectedAgentName = currentConversation.AgentName;
+ }
+ }
- Logger.LogInformation("Closed conversation with session: {SessionId}", sessionId);
- }
- StateHasChanged();
- }
+ Logger.LogInformation("Closed conversation with session: {SessionId}", sessionId);
+ }
+ StateHasChanged();
+ }
- private async Task SendMessage()
- {
- if (string.IsNullOrWhiteSpace(currentMessage) || isStreaming || currentConversation == null)
- return;
+ private async Task SendMessage()
+ {
+ if (string.IsNullOrWhiteSpace(currentMessage) || isStreaming || currentConversation == null)
+ return;
- var userMessage = currentMessage.Trim();
- currentMessage = "";
+ var userMessage = currentMessage.Trim();
+ currentMessage = "";
- Logger.LogInformation("User sending message: '{UserMessage}' to agent {AgentName} in session {SessionId}",
- userMessage, currentConversation.AgentName, currentConversation.SessionId);
+ Logger.LogInformation("User sending message: '{UserMessage}' to agent {AgentName} in session {SessionId}",
+ userMessage, currentConversation.AgentName, currentConversation.SessionId);
- // Add user message to chat
- currentConversation.Messages.Add(new ChatMessage(ChatRole.User, userMessage));
- StateHasChanged();
- await ScrollToBottom();
+ // Add user message to chat
+ currentConversation.Messages.Add(new ChatMessage(ChatRole.User, userMessage));
+ StateHasChanged();
+ await ScrollToBottom();
- // Start streaming response
- isStreaming = true;
- currentStreamedMessage = "";
- StateHasChanged();
+ // Start streaming response
+ isStreaming = true;
+ currentStreamedMessage = "";
+ StateHasChanged();
- try
- {
- var responseContent = new StringBuilder();
- var agent = new AgentProxy(currentConversation.AgentName, ActorClient);
+ StringBuilder responseContent = new();
+ var hasReceivedContent = false;
+
+ using var timeoutCts = new CancellationTokenSource(
+#if DEBUG
+ TimeSpan.FromSeconds(120)
+#else
+ TimeSpan.FromSeconds(20)
+#endif
+ );
+
+ try
+ {
+ var actorClient = (selectedProtocol is Protocol.A2A) ? A2AActorClient : ActorClient;
+ var agent = new AgentProxy(currentConversation.AgentName, actorClient);
var thread = agent.GetNewThread();
thread.ConversationId = currentConversation.SessionId;
await foreach (var update in agent.RunStreamingAsync(
- [new ChatMessage(ChatRole.User, userMessage)],
+ [new ChatMessage(ChatRole.User, userMessage)],
thread,
- cancellationToken: CancellationToken.None))
+ cancellationToken: timeoutCts.Token))
{
var content = update.Text ?? "";
if (!string.IsNullOrEmpty(content))
{
+ hasReceivedContent = true;
responseContent.Append(content);
currentStreamedMessage = responseContent.ToString();
StateHasChanged();
await ScrollToBottom();
+
+ Logger.LogDebug("Received streaming content: {ContentLength} characters", content.Length);
}
}
+ Logger.LogInformation("Streaming completed for session {SessionId}, total content length: {ContentLength}",
+ currentConversation.SessionId, responseContent.Length);
+
// Add the complete agent response to chat messages
if (responseContent.Length > 0)
{
currentConversation.Messages.Add(new ChatMessage(ChatRole.Assistant, responseContent.ToString()));
}
+ else if (!hasReceivedContent)
+ {
+ Logger.LogWarning("No content received during streaming for session {SessionId}", currentConversation.SessionId);
+ currentConversation.Messages.Add(new ChatMessage(ChatRole.Assistant, "No response received from the agent."));
+ }
else
{
currentConversation.Messages.Add(new ChatMessage(ChatRole.Assistant, "Sorry, I couldn't generate a response."));
}
}
+ catch (OperationCanceledException) when (isStreaming)
+ {
+ Logger.LogWarning("Streaming operation timed out for session {SessionId}", currentConversation.SessionId);
+ currentConversation.Messages.Add(new ChatMessage(ChatRole.Assistant, "Request timed out. Please try again."));
+ }
catch (Exception ex)
{
- Logger.LogError(ex, "Error occurred while processing message in session {SessionId}: {ErrorMessage}",
+ Logger.LogError(ex, "Error occurred while processing message in session {SessionId}: {ErrorMessage}",
currentConversation.SessionId, ex.Message);
currentConversation.Messages.Add(new ChatMessage(ChatRole.Assistant, $"Error: {ex.Message}"));
- }
+ }
finally
{
isStreaming = false;
diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Program.cs
index 1173f2de29..7d6eae8199 100644
--- a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Program.cs
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Program.cs
@@ -15,8 +15,19 @@ builder.Services.AddRazorComponents()
builder.Services.AddOutputCache();
-builder.Services.AddHttpClient(client => client.BaseAddress = new("https+http://agenthost"));
-builder.Services.AddHttpClient(client => client.BaseAddress = new("https+http://agenthost"));
+// This URL uses "https+http://" to indicate HTTPS is preferred over HTTP.
+// Learn more about service discovery scheme resolution at https://aka.ms/dotnet/sdschemes.
+Uri baseAddress = new("https+http://agenthost");
+
+// for some reason does not resolve with `apiservice` url
+Uri a2aAddress = new("http://localhost:5390/a2a");
+
+builder.Services.AddHttpClient(client => client.BaseAddress = baseAddress);
+builder.Services.AddHttpClient(client => client.BaseAddress = baseAddress);
+builder.Services.AddSingleton(sp =>
+{
+ return new A2AActorClient(sp.GetRequiredService>(), a2aAddress);
+});
var app = builder.Build();
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore/Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore.csproj b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore/Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore.csproj
new file mode 100644
index 0000000000..3dfe651782
--- /dev/null
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore/Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore.csproj
@@ -0,0 +1,22 @@
+
+
+
+ $(ProjectsCoreTargetFrameworks)
+ $(ProjectsCoreTargetFrameworks)
+ $(NoWarn);IDE1006;IDE0130;NU1504
+ Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore
+ alpha
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore/WebApplicationExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore/WebApplicationExtensions.cs
new file mode 100644
index 0000000000..504d76037c
--- /dev/null
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore/WebApplicationExtensions.cs
@@ -0,0 +1,70 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using A2A;
+using A2A.AspNetCore;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.AI.Agents.Runtime;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore;
+
+///
+/// Provides extension methods for configuring A2A (Agent-to-Agent) communication in a host application builder.
+///
+public static class WebApplicationExtensions
+{
+ ///
+ /// Attaches A2A (Agent-to-Agent) communication capabilities via Message processing to the specified web application.
+ ///
+ /// The web application used to configure the pipeline and routes.
+ /// The name of the agent to use for A2A protocol integration.
+ /// The route group to use for A2A endpoints.
+ public static void AttachA2A(this WebApplication app, string agentName, string path)
+ {
+ var agent = app.Services.GetRequiredKeyedService(agentName);
+ var loggerFactory = app.Services.GetRequiredService();
+ var actorClient = app.Services.GetRequiredService();
+
+ var taskManager = agent.AttachA2A(actorClient, loggerFactory: loggerFactory);
+ app.AttachA2A(taskManager, path);
+ }
+
+ ///
+ /// Attaches A2A (Agent-to-Agent) communication capabilities via Message processing to the specified web application.
+ ///
+ /// The web application used to configure the pipeline and routes.
+ /// The name of the agent to use for A2A protocol integration.
+ /// The route group to use for A2A endpoints.
+ /// Agent card info to return on query.
+ public static void AttachA2A(
+ this WebApplication app,
+ string agentName,
+ string path,
+ AgentCard agentCard)
+ {
+ var agent = app.Services.GetRequiredKeyedService(agentName);
+ var loggerFactory = app.Services.GetRequiredService();
+ var actorClient = app.Services.GetRequiredService();
+
+ var taskManager = agent.AttachA2A(actorClient, agentCard: agentCard, loggerFactory: loggerFactory);
+ app.AttachA2A(taskManager, path);
+ }
+
+ ///
+ /// Maps HTTP A2A communication endpoints to the specified path using the provided TaskManager.
+ /// TaskManager should be preconfigured before calling this method.
+ ///
+ /// The web application used to configure the pipeline and routes.
+ /// Pre-configured A2A TaskManager to use for A2A endpoints handling.
+ /// The route group to use for A2A endpoints.
+ public static void AttachA2A(this WebApplication app, TaskManager taskManager, string path)
+ {
+ // note: current SDK version registers multiple `.well-known/agent.json` handlers here.
+ // it makes app return HTTP 500, but will be fixed once new A2A SDK is released.
+ // see https://github.com/microsoft/agent-framework/issues/476 for details
+ app.MapA2A(taskManager, path);
+
+ app.MapHttpA2A(taskManager, path);
+ }
+}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/AIAgentExtensions.cs
new file mode 100644
index 0000000000..e48e499903
--- /dev/null
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/AIAgentExtensions.cs
@@ -0,0 +1,74 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Threading.Tasks;
+using A2A;
+using Microsoft.Extensions.AI.Agents.Hosting.A2A.Internal;
+using Microsoft.Extensions.AI.Agents.Runtime;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.Extensions.AI.Agents.Hosting.A2A;
+
+///
+/// Provides extension methods for attaching A2A (Agent-to-Agent) messaging capabilities to an .
+///
+public static class AIAgentExtensions
+{
+ ///
+ /// Attaches A2A (Agent-to-Agent) messaging capabilities via Message processing to the specified .
+ ///
+ /// Agent to attach A2A messaging processing capabilities to.
+ /// The actor client implementation to use.
+ /// Instance of to configure for A2A messaging. New instance will be created if not passed.
+ /// The logger factory to use for creating instances.
+ /// The configured .
+ public static TaskManager AttachA2A(
+ this AIAgent agent,
+ IActorClient actorClient,
+ TaskManager? taskManager = null,
+ ILoggerFactory? loggerFactory = null)
+ {
+ ArgumentNullException.ThrowIfNull(agent, nameof(agent));
+ ArgumentNullException.ThrowIfNull(actorClient, nameof(actorClient));
+
+ taskManager ??= new();
+
+ var a2aAgentWrapper = new A2AAgentWrapper(actorClient, agent, loggerFactory);
+
+ taskManager.OnMessageReceived += a2aAgentWrapper.ProcessMessageAsync;
+
+ return taskManager;
+ }
+
+ ///
+ /// Attaches A2A (Agent-to-Agent) messaging capabilities via Message processing to the specified .
+ ///
+ /// Agent to attach A2A messaging processing capabilities to.
+ /// The actor client implementation to use.
+ /// The agent card to return on query.
+ /// Instance of to configure for A2A messaging. New instance will be created if not passed.
+ /// The logger factory to use for creating instances.
+ /// The configured .
+ public static TaskManager AttachA2A(
+ this AIAgent agent,
+ IActorClient actorClient,
+ AgentCard agentCard,
+ TaskManager? taskManager = null,
+ ILoggerFactory? loggerFactory = null)
+ {
+ taskManager = agent.AttachA2A(actorClient, taskManager, loggerFactory);
+
+ taskManager.OnAgentCardQuery += (context, query) =>
+ {
+ if (agentCard.Url is null)
+ {
+ // A2A SDK assigns the url on its own
+ // we can help user if they did not set Url explicitly.
+ agentCard.Url = context;
+ }
+
+ return Task.FromResult(agentCard);
+ };
+ return taskManager;
+ }
+}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Converters/ActorEntitiesConverter.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Converters/ActorEntitiesConverter.cs
new file mode 100644
index 0000000000..e3440ae9b1
--- /dev/null
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Converters/ActorEntitiesConverter.cs
@@ -0,0 +1,58 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Text.Json;
+using A2A;
+using Microsoft.Extensions.AI.Agents.Runtime;
+
+namespace Microsoft.Extensions.AI.Agents.Hosting.A2A.Converters;
+
+internal static class ActorEntitiesConverter
+{
+ public static Message ToMessage(this ActorResponse response)
+ {
+ var agentRunResponse = response.Data.Deserialize(AgentHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponse))) as AgentRunResponse;
+ if (agentRunResponse is null)
+ {
+ throw new ArgumentException("The ActorResponse data could not be deserialized to an AgentRunResponse.", nameof(response));
+ }
+
+ var contextId = response.ActorId.Key;
+ var parts = agentRunResponse.Messages.ToParts();
+
+ return new Message
+ {
+ MessageId = response.MessageId ?? Guid.NewGuid().ToString(),
+ ContextId = contextId,
+ Role = MessageRole.Agent,
+ Parts = parts
+ };
+ }
+
+ public static ActorRequestUpdate ToActorRequestUpdate(this Message message, RequestStatus status = RequestStatus.Completed)
+ {
+ // maybe we need to split to chatmessage-per-part, but the idea to map is clear
+ var chatMessage = message.ToChatMessage();
+ if (chatMessage is null)
+ {
+ throw new ArgumentException("The Message could not be converted to a ChatMessage.", nameof(message));
+ }
+
+ var agentRunResponseUpdate = new AgentRunResponseUpdate(ChatRole.Assistant, chatMessage.Contents);
+ var updateTypeInfo = AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponseUpdate));
+ var jsonElement = JsonSerializer.SerializeToElement(agentRunResponseUpdate, updateTypeInfo);
+ return new ActorRequestUpdate(status, jsonElement);
+ }
+
+ public static AgentRunResponse ToAgentRunResponse(this Message message)
+ {
+ // maybe we need to split to chatmessage-per-part, but the idea to map is clear
+ var chatMessage = message.ToChatMessage();
+ if (chatMessage is null)
+ {
+ throw new ArgumentException("The Message could not be converted to a ChatMessage.", nameof(message));
+ }
+
+ return new AgentRunResponse(chatMessage);
+ }
+}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Converters/MessageConverter.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Converters/MessageConverter.cs
new file mode 100644
index 0000000000..b95c241803
--- /dev/null
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Converters/MessageConverter.cs
@@ -0,0 +1,261 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using A2A;
+
+namespace Microsoft.Extensions.AI.Agents.Hosting.A2A.Converters;
+
+internal static class MessageConverter
+{
+ public static List ToParts(this IList chatMessages)
+ {
+ if (chatMessages is null || chatMessages.Count == 0)
+ {
+ return [];
+ }
+
+ var parts = new List();
+ foreach (var chatMessage in chatMessages)
+ {
+ foreach (var content in chatMessage.Contents)
+ {
+ var part = ConvertAIContentToPart(content);
+ if (part != null)
+ {
+ parts.Add(part);
+ }
+ }
+
+ // If no parts were created from content, create a text part from the message text
+ if (chatMessage.Contents.Count == 0 && !string.IsNullOrEmpty(chatMessage.Text))
+ {
+ parts.Add(new TextPart { Text = chatMessage.Text });
+ }
+ }
+
+ return parts;
+ }
+
+ ///
+ /// Converts A2A MessageSendParams to a collection of Microsoft.Extensions.AI ChatMessage objects.
+ ///
+ /// The A2A message send parameters to convert.
+ /// A read-only collection of ChatMessage objects.
+ public static List ToChatMessages(this MessageSendParams messageSendParams)
+ {
+ if (messageSendParams is null)
+ {
+ return [];
+ }
+
+ var result = new List();
+ if (messageSendParams.Message?.Parts != null)
+ {
+ var chatMessage = ToChatMessage(messageSendParams.Message);
+ if (chatMessage is not null)
+ {
+ result.Add(chatMessage);
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// Converts collection of A2A to a collection of objects.
+ ///
+ /// A read-only collection of ChatMessage objects.
+ public static IReadOnlyCollection ToChatMessages(this ICollection messages)
+ {
+ if (messages is null || messages.Count == 0)
+ {
+ return [];
+ }
+
+ var result = new List();
+ foreach (var message in messages)
+ {
+ var chatMessage = ToChatMessage(message);
+ if (chatMessage is not null)
+ {
+ result.Add(chatMessage);
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// Converts a single to a .
+ ///
+ /// The A2A message to convert.
+ /// A ChatMessage object, or null if conversion is not possible.
+ public static ChatMessage? ToChatMessage(this Message message)
+ {
+ if (message?.Parts == null || message.Parts.Count == 0)
+ {
+ return null;
+ }
+
+ var chatRole = ConvertMessageRoleToChatRole(message.Role);
+
+ var content = new List();
+ foreach (var part in message.Parts)
+ {
+ var aiContent = ConvertPartToAIContent(part);
+ if (aiContent is not null)
+ {
+ content.Add(aiContent);
+ }
+ }
+
+ // If no valid content was extracted, return null
+ if (content.Count == 0)
+ {
+ return null;
+ }
+
+ // Create the ChatMessage with appropriate metadata
+ var chatMessage = new ChatMessage(chatRole, content)
+ {
+ MessageId = message.MessageId,
+ RawRepresentation = message
+ };
+
+ // Add any additional properties if needed
+ if (message.Metadata is not null)
+ {
+ chatMessage.AdditionalProperties = message.Metadata.ToAdditionalPropertiesDictionary();
+ }
+
+ return chatMessage;
+ }
+
+ ///
+ /// Converts A2A MessageRole to Microsoft.Extensions.AI ChatRole.
+ ///
+ /// The A2A message role.
+ /// The corresponding ChatRole.
+ private static ChatRole ConvertMessageRoleToChatRole(MessageRole messageRole) => messageRole switch
+ {
+ MessageRole.User => ChatRole.User,
+ MessageRole.Agent => ChatRole.Assistant,
+ _ => ChatRole.User
+ };
+
+ ///
+ /// Converts an A2A Part to Microsoft.Extensions.AI AIContent.
+ ///
+ /// The A2A part to convert.
+ /// An AIContent object, or null if conversion is not possible.
+#pragma warning disable CA1859 // Use concrete types when possible for improved performance
+ private static AIContent? ConvertPartToAIContent(Part part)
+#pragma warning restore CA1859 // Use concrete types when possible for improved performance
+ {
+ var result = part switch
+ {
+ TextPart textPart => new TextContent(textPart.Text)
+ {
+ RawRepresentation = textPart,
+ AdditionalProperties = textPart.Metadata?.ToAdditionalPropertiesDictionary()
+ },
+ FilePart or DataPart or _ => throw new NotSupportedException($"Part type '{part.GetType().Name}' is not supported. Only TextPart is supported.")
+ };
+
+ return result;
+ }
+
+ ///
+ /// Converts Microsoft.Extensions.AI ChatMessage back to A2A Message format.
+ /// This is useful for the reverse operation.
+ ///
+ /// The ChatMessage to convert.
+ /// An A2A Message object.
+ public static Message ToA2AMessage(this ChatMessage chatMessage)
+ {
+ if (chatMessage == null)
+ {
+ throw new ArgumentNullException(nameof(chatMessage));
+ }
+
+ var message = new Message
+ {
+ MessageId = chatMessage.MessageId ?? System.Guid.NewGuid().ToString(),
+ Role = ConvertChatRoleToMessageRole(chatMessage.Role),
+ Parts = new List()
+ };
+
+ // Convert content to parts
+ foreach (var content in chatMessage.Contents)
+ {
+ var part = ConvertAIContentToPart(content);
+ if (part != null)
+ {
+ message.Parts.Add(part);
+ }
+ }
+
+ // If no parts were created from content, create a text part from the message text
+ if (message.Parts.Count == 0 && !string.IsNullOrEmpty(chatMessage.Text))
+ {
+ message.Parts.Add(new TextPart { Text = chatMessage.Text });
+ }
+
+ return message;
+ }
+
+ ///
+ /// Converts Microsoft.Extensions.AI ChatRole to A2A MessageRole.
+ ///
+ /// The ChatRole to convert.
+ /// The corresponding MessageRole.
+ private static MessageRole ConvertChatRoleToMessageRole(ChatRole chatRole)
+ {
+ if (chatRole == ChatRole.User)
+ {
+ return MessageRole.User;
+ }
+ if (chatRole == ChatRole.Assistant)
+ {
+ return MessageRole.Agent;
+ }
+
+ return MessageRole.User; // Default fallback
+ }
+
+ ///
+ /// Converts Microsoft.Extensions.AI AIContent to A2A Part.
+ ///
+ /// The AIContent to convert.
+ /// A Part object, or null if conversion is not possible.
+#pragma warning disable CA1859 // Use concrete types when possible for improved performance
+ private static Part? ConvertAIContentToPart(AIContent content)
+#pragma warning restore CA1859 // Use concrete types when possible for improved performance
+ {
+ return content switch
+ {
+ TextContent textContent => new TextPart
+ {
+ Text = textContent.Text
+ },
+ _ => throw new NotSupportedException($"Content type '{content.GetType().Name}' is not supported.")
+ };
+ }
+
+ private static AdditionalPropertiesDictionary? ToAdditionalPropertiesDictionary(this Dictionary metadata)
+ {
+ if (metadata == null || metadata.Count == 0)
+ {
+ return null;
+ }
+
+ var additionalProperties = new AdditionalPropertiesDictionary();
+ foreach (var kvp in metadata)
+ {
+ additionalProperties[kvp.Key] = kvp.Value;
+ }
+ return additionalProperties;
+ }
+}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Internal/A2AAgentWrapper.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Internal/A2AAgentWrapper.cs
new file mode 100644
index 0000000000..9c97b3d88f
--- /dev/null
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Internal/A2AAgentWrapper.cs
@@ -0,0 +1,70 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using A2A;
+using Microsoft.Extensions.AI.Agents.Hosting.A2A.Converters;
+using Microsoft.Extensions.AI.Agents.Runtime;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Microsoft.Extensions.AI.Agents.Hosting.A2A.Internal;
+
+///
+/// A2A agent that wraps an existing AIAgent and provides A2A-specific thread wrapping.
+///
+internal sealed class A2AAgentWrapper
+{
+ private readonly ILogger _logger;
+ private readonly AIAgent _innerAgent;
+ private readonly IActorClient _actorClient;
+
+ public A2AAgentWrapper(
+ IActorClient actorClient,
+ AIAgent innerAgent,
+ ILoggerFactory? loggerFactory = null)
+ {
+ this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger();
+
+ this._actorClient = actorClient;
+ this._innerAgent = innerAgent ?? throw new ArgumentNullException(nameof(innerAgent));
+ }
+
+ public async Task ProcessMessageAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken)
+ {
+ var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString();
+ var messageId = messageSendParams.Message.MessageId;
+
+ var actorId = new ActorId(type: this.GetActorType(), key: contextId!);
+
+ // Verify request does not exist already
+ var existingResponseHandle = await this._actorClient.GetResponseAsync(actorId, messageId, cancellationToken).ConfigureAwait(false);
+ var existingResponse = await existingResponseHandle.GetResponseAsync(cancellationToken).ConfigureAwait(false);
+ if (existingResponse.Status is RequestStatus.Completed or RequestStatus.Failed)
+ {
+ return existingResponse.ToMessage();
+ }
+
+ // here we know we did not yet send the request, so lets do it
+ var chatMessages = messageSendParams.ToChatMessages();
+ var runRequest = new AgentRunRequest
+ {
+ Messages = chatMessages
+ };
+ var @params = JsonSerializer.SerializeToElement(runRequest, AgentHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunRequest)));
+
+ var requestHandle = await this._actorClient.SendRequestAsync(new ActorRequest(actorId, messageId, method: "Run" /* ?refer to const here? */, @params: @params), cancellationToken).ConfigureAwait(false);
+ var response = await requestHandle.GetResponseAsync(cancellationToken).ConfigureAwait(false);
+
+ return response.ToMessage();
+ }
+
+ private ActorType GetActorType()
+ {
+ // agent is registered in DI via name
+ ArgumentException.ThrowIfNullOrEmpty(this._innerAgent.Name);
+ return new ActorType(this._innerAgent.Name);
+ }
+}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Microsoft.Extensions.AI.Agents.Hosting.A2A.csproj b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Microsoft.Extensions.AI.Agents.Hosting.A2A.csproj
new file mode 100644
index 0000000000..4b0d9ae271
--- /dev/null
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Microsoft.Extensions.AI.Agents.Hosting.A2A.csproj
@@ -0,0 +1,33 @@
+
+
+
+ $(ProjectsCoreTargetFrameworks)
+ $(ProjectsCoreTargetFrameworks)
+ $(NoWarn);IDE1006;IDE0130;NU1504
+ Microsoft.Extensions.AI.Agents.Hosting.A2A
+ alpha
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.A2A.Tests/Converters/MessageConverterTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.A2A.Tests/Converters/MessageConverterTests.cs
new file mode 100644
index 0000000000..24ce1d3892
--- /dev/null
+++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.A2A.Tests/Converters/MessageConverterTests.cs
@@ -0,0 +1,531 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using A2A;
+using Microsoft.Extensions.AI.Agents.Hosting.A2A.Converters;
+
+namespace Microsoft.Extensions.AI.Agents.Hosting.A2A.Tests.Converters;
+
+public class MessageConverterTests
+{
+ [Fact]
+ public void ToChatMessages_MessageSendParams_Null_ReturnsEmptyCollection()
+ {
+ MessageSendParams? messageSendParams = null;
+
+ var result = messageSendParams!.ToChatMessages();
+
+ Assert.NotNull(result);
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void ToChatMessages_MessageSendParams_WithNullMessage_ReturnsEmptyCollection()
+ {
+ var messageSendParams = new MessageSendParams
+ {
+ Message = null!
+ };
+
+ var result = messageSendParams.ToChatMessages();
+
+ Assert.NotNull(result);
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void ToChatMessages_MessageSendParams_WithMessageWithoutParts_ReturnsEmptyCollection()
+ {
+ var messageSendParams = new MessageSendParams
+ {
+ Message = new Message
+ {
+ MessageId = "test-id",
+ Role = MessageRole.User,
+ Parts = null!
+ }
+ };
+
+ var result = messageSendParams.ToChatMessages();
+
+ Assert.NotNull(result);
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void ToChatMessages_MessageSendParams_WithValidTextMessage_ReturnsCorrectChatMessage()
+ {
+ var messageSendParams = new MessageSendParams
+ {
+ Message = new Message
+ {
+ MessageId = "test-id",
+ Role = MessageRole.User,
+ Parts = new List
+ {
+ new TextPart { Text = "Hello, world!" }
+ }
+ }
+ };
+
+ var result = messageSendParams.ToChatMessages();
+
+ Assert.NotNull(result);
+ Assert.Single(result);
+
+ var chatMessage = result.First();
+ Assert.Equal("test-id", chatMessage.MessageId);
+ Assert.Equal(ChatRole.User, chatMessage.Role);
+ Assert.Single(chatMessage.Contents);
+
+ var textContent = Assert.IsType(chatMessage.Contents.First());
+ Assert.Equal("Hello, world!", textContent.Text);
+ }
+
+ [Fact]
+ public void ToChatMessages_MessageCollection_Null_ReturnsEmptyCollection()
+ {
+ ICollection? messages = null;
+
+ var result = messages!.ToChatMessages();
+
+ Assert.NotNull(result);
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void ToChatMessages_MessageCollection_Empty_ReturnsEmptyCollection()
+ {
+ var messages = new List();
+
+ var result = messages.ToChatMessages();
+
+ Assert.NotNull(result);
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void ToChatMessages_MessageCollection_WithValidMessages_ReturnsCorrectChatMessages()
+ {
+ var messages = new List
+ {
+ new()
+ {
+ MessageId = "user-msg",
+ Role = MessageRole.User,
+ Parts = new List { new TextPart { Text = "User message" } }
+ },
+ new()
+ {
+ MessageId = "agent-msg",
+ Role = MessageRole.Agent,
+ Parts = new List { new TextPart { Text = "Agent response" } }
+ }
+ };
+
+ var result = messages.ToChatMessages();
+
+ Assert.NotNull(result);
+ Assert.Equal(2, result.Count);
+
+ var userMessage = result.First();
+ Assert.Equal("user-msg", userMessage.MessageId);
+ Assert.Equal(ChatRole.User, userMessage.Role);
+ Assert.Equal("User message", ((TextContent)userMessage.Contents.First()).Text);
+
+ var agentMessage = result.Skip(1).First();
+ Assert.Equal("agent-msg", agentMessage.MessageId);
+ Assert.Equal(ChatRole.Assistant, agentMessage.Role);
+ Assert.Equal("Agent response", ((TextContent)agentMessage.Contents.First()).Text);
+ }
+
+ [Fact]
+ public void ToChatMessages_MessageCollection_SkipsInvalidMessages_ReturnsValidChatMessages()
+ {
+ var messages = new List
+ {
+ new()
+ {
+ MessageId = "valid-msg",
+ Role = MessageRole.User,
+ Parts = new List { new TextPart { Text = "Valid message" } }
+ },
+ new()
+ {
+ MessageId = "invalid-msg",
+ Role = MessageRole.User,
+ Parts = null! // Invalid - no parts
+ }
+ };
+
+ var result = messages.ToChatMessages();
+
+ Assert.NotNull(result);
+ Assert.Single(result);
+
+ var validMessage = result.First();
+ Assert.Equal("valid-msg", validMessage.MessageId);
+ Assert.Equal("Valid message", ((TextContent)validMessage.Contents.First()).Text);
+ }
+
+ [Fact]
+ public void ToA2AMessage_NullChatMessage_ThrowsArgumentNullException()
+ {
+ ChatMessage? chatMessage = null;
+
+ Assert.Throws(() => chatMessage!.ToA2AMessage());
+ }
+
+ [Fact]
+ public void ToA2AMessage_ValidChatMessage_ReturnsCorrectA2AMessage()
+ {
+ var chatMessage = new ChatMessage(ChatRole.User, "Hello, world!")
+ {
+ MessageId = "test-id"
+ };
+
+ var result = chatMessage.ToA2AMessage();
+
+ Assert.NotNull(result);
+ Assert.Equal("test-id", result.MessageId);
+ Assert.Equal(MessageRole.User, result.Role);
+ Assert.Single(result.Parts);
+
+ var textPart = Assert.IsType(result.Parts.First());
+ Assert.Equal("Hello, world!", textPart.Text);
+ }
+
+ [Fact]
+ public void ToA2AMessage_ChatMessageWithoutMessageId_GeneratesNewMessageId()
+ {
+ var chatMessage = new ChatMessage(ChatRole.Assistant, "Response message");
+
+ var result = chatMessage.ToA2AMessage();
+
+ Assert.NotNull(result);
+ Assert.NotNull(result.MessageId);
+ Assert.NotEmpty(result.MessageId);
+ Assert.Equal(MessageRole.Agent, result.Role);
+ }
+
+ [Fact]
+ public void ToA2AMessage_ChatMessageWithTextContent_ReturnsCorrectTextPart()
+ {
+ var textContent = new TextContent("Test content");
+ var chatMessage = new ChatMessage(ChatRole.User, [textContent]);
+
+ var result = chatMessage.ToA2AMessage();
+
+ Assert.NotNull(result);
+ Assert.Single(result.Parts);
+
+ var textPart = Assert.IsType(result.Parts.First());
+ Assert.Equal("Test content", textPart.Text);
+ }
+
+ [Fact]
+ public void ToA2AMessage_ChatMessageWithUnsupportedContent_ThrowsNotSupportedException()
+ {
+ var unsupportedContent = new DataContent(new byte[] { 1, 2, 3 }, "image/png");
+ var chatMessage = new ChatMessage(ChatRole.User, [unsupportedContent]);
+
+ var exception = Assert.Throws(() => chatMessage.ToA2AMessage());
+ Assert.Contains("Content type 'DataContent' is not supported", exception.Message);
+ }
+
+ [Fact]
+ public void ToA2AMessage_ChatMessageWithEmptyContent_CreatesTextPartFromMessageText()
+ {
+ var chatMessage = new ChatMessage(ChatRole.User, "Fallback text");
+
+ var result = chatMessage.ToA2AMessage();
+
+ Assert.NotNull(result);
+ Assert.Single(result.Parts);
+
+ var textPart = Assert.IsType(result.Parts.First());
+ Assert.Equal("Fallback text", textPart.Text);
+ }
+
+ [Fact]
+ public void ConvertMessageRoleToChatRole_UserRole_ReturnsUserChatRole()
+ {
+ var message = new Message
+ {
+ MessageId = "test",
+ Role = MessageRole.User,
+ Parts = new List { new TextPart { Text = "Test" } }
+ };
+
+ var result = new List { message }.ToChatMessages();
+
+ var chatMessage = result.First();
+ Assert.Equal(ChatRole.User, chatMessage.Role);
+ }
+
+ [Fact]
+ public void ConvertMessageRoleToChatRole_AgentRole_ReturnsAssistantChatRole()
+ {
+ var message = new Message
+ {
+ MessageId = "test",
+ Role = MessageRole.Agent,
+ Parts = new List { new TextPart { Text = "Test" } }
+ };
+
+ var result = new List { message }.ToChatMessages();
+
+ var chatMessage = result.First();
+ Assert.Equal(ChatRole.Assistant, chatMessage.Role);
+ }
+
+ [Fact]
+ public void ConvertMessageRoleToChatRole_UnknownRole_ReturnsUserChatRole()
+ {
+ var message = new Message
+ {
+ MessageId = "test",
+ Role = (MessageRole)999, // Unknown role
+ Parts = new List { new TextPart { Text = "Test" } }
+ };
+
+ var result = new List { message }.ToChatMessages();
+
+ var chatMessage = result.First();
+ Assert.Equal(ChatRole.User, chatMessage.Role);
+ }
+
+ [Fact]
+ public void ConvertChatRoleToMessageRole_UserRole_ReturnsUserMessageRole()
+ {
+ var chatMessage = new ChatMessage(ChatRole.User, "Test message");
+
+ var result = chatMessage.ToA2AMessage();
+
+ Assert.Equal(MessageRole.User, result.Role);
+ }
+
+ [Fact]
+ public void ConvertChatRoleToMessageRole_AssistantRole_ReturnsAgentMessageRole()
+ {
+ var chatMessage = new ChatMessage(ChatRole.Assistant, "Test message");
+
+ var result = chatMessage.ToA2AMessage();
+
+ Assert.Equal(MessageRole.Agent, result.Role);
+ }
+
+ [Fact]
+ public void ConvertChatRoleToMessageRole_SystemRole_ReturnsUserMessageRole()
+ {
+ var chatMessage = new ChatMessage(ChatRole.System, "Test message");
+
+ var result = chatMessage.ToA2AMessage();
+
+ Assert.Equal(MessageRole.User, result.Role);
+ }
+
+ [Fact]
+ public void ConvertChatRoleToMessageRole_ToolRole_ReturnsUserMessageRole()
+ {
+ var chatMessage = new ChatMessage(ChatRole.Tool, "Test message");
+
+ var result = chatMessage.ToA2AMessage();
+
+ Assert.Equal(MessageRole.User, result.Role);
+ }
+
+ [Fact]
+ public void ConvertPartToAIContent_TextPart_ReturnsTextContent()
+ {
+ var textPart = new TextPart { Text = "Sample text" };
+ var message = new Message
+ {
+ MessageId = "test",
+ Role = MessageRole.User,
+ Parts = new List { textPart }
+ };
+
+ var result = new List { message }.ToChatMessages();
+
+ var chatMessage = result.First();
+ var textContent = Assert.IsType(chatMessage.Contents.First());
+ Assert.Equal("Sample text", textContent.Text);
+ Assert.Equal(textPart, textContent.RawRepresentation);
+ }
+
+ [Fact]
+ public void ConvertPartToAIContent_TextPartWithMetadata_PreservesMetadata()
+ {
+ var metadata = new Dictionary
+ {
+ ["key1"] = JsonDocument.Parse("\"value1\"").RootElement,
+ ["key2"] = JsonDocument.Parse("42").RootElement
+ };
+ var textPart = new TextPart
+ {
+ Text = "Text with metadata",
+ Metadata = metadata
+ };
+ var message = new Message
+ {
+ MessageId = "test",
+ Role = MessageRole.User,
+ Parts = new List { textPart }
+ };
+
+ var result = new List { message }.ToChatMessages();
+
+ var chatMessage = result.First();
+ var textContent = Assert.IsType(chatMessage.Contents.First());
+ Assert.NotNull(textContent.AdditionalProperties);
+ Assert.Equal(2, textContent.AdditionalProperties.Count);
+ Assert.True(textContent.AdditionalProperties.ContainsKey("key1"));
+ Assert.True(textContent.AdditionalProperties.ContainsKey("key2"));
+ }
+
+ [Fact]
+ public void ConvertPartToAIContent_FilePart_ThrowsNotSupportedException()
+ {
+ var filePart = new FilePart();
+ var message = new Message
+ {
+ MessageId = "test",
+ Role = MessageRole.User,
+ Parts = new List { filePart }
+ };
+
+ var exception = Assert.Throws(() => new List { message }.ToChatMessages());
+ Assert.Contains("Part type 'FilePart' is not supported", exception.Message);
+ }
+
+ [Fact]
+ public void ConvertPartToAIContent_DataPart_ThrowsNotSupportedException()
+ {
+ var dataPart = new DataPart();
+ var message = new Message
+ {
+ MessageId = "test",
+ Role = MessageRole.User,
+ Parts = new List { dataPart }
+ };
+
+ var exception = Assert.Throws(() => new List { message }.ToChatMessages());
+ Assert.Contains("Part type 'DataPart' is not supported", exception.Message);
+ }
+
+ [Fact]
+ public void ConvertMessageToChatMessage_WithMetadata_PreservesMetadataInAdditionalProperties()
+ {
+ var metadata = new Dictionary
+ {
+ ["timestamp"] = JsonDocument.Parse("\"2024-01-01T00:00:00Z\"").RootElement,
+ ["priority"] = JsonDocument.Parse("1").RootElement
+ };
+ var message = new Message
+ {
+ MessageId = "test-id",
+ Role = MessageRole.User,
+ Parts = new List { new TextPart { Text = "Test" } },
+ Metadata = metadata
+ };
+
+ var result = new List { message }.ToChatMessages();
+
+ var chatMessage = result.First();
+ Assert.NotNull(chatMessage.AdditionalProperties);
+ Assert.Equal(2, chatMessage.AdditionalProperties.Count);
+ Assert.True(chatMessage.AdditionalProperties.ContainsKey("timestamp"));
+ Assert.True(chatMessage.AdditionalProperties.ContainsKey("priority"));
+ }
+
+ [Fact]
+ public void ConvertMessageToChatMessage_WithRawRepresentation_PreservesOriginalMessage()
+ {
+ var message = new Message
+ {
+ MessageId = "test-id",
+ Role = MessageRole.Agent,
+ Parts = new List { new TextPart { Text = "Test response" } }
+ };
+
+ var result = new List { message }.ToChatMessages();
+
+ var chatMessage = result.First();
+ Assert.Equal(message, chatMessage.RawRepresentation);
+ }
+
+ [Fact]
+ public void ToChatMessages_MessageWithEmptyParts_ReturnsEmptyCollection()
+ {
+ var message = new Message
+ {
+ MessageId = "test-id",
+ Role = MessageRole.User,
+ Parts = new List() // Empty list
+ };
+
+ var result = new List { message }.ToChatMessages();
+
+ Assert.NotNull(result);
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void ToA2AMessage_ChatMessageWithMultipleTextContents_CreatesMultipleParts()
+ {
+ var contents = new List
+ {
+ new TextContent("First part"),
+ new TextContent("Second part")
+ };
+ var chatMessage = new ChatMessage(ChatRole.User, contents);
+
+ var result = chatMessage.ToA2AMessage();
+
+ Assert.NotNull(result);
+ Assert.Equal(2, result.Parts.Count);
+
+ var firstPart = Assert.IsType(result.Parts[0]);
+ var secondPart = Assert.IsType(result.Parts[1]);
+ Assert.Equal("First part", firstPart.Text);
+ Assert.Equal("Second part", secondPart.Text);
+ }
+
+ [Fact]
+ public void ToAdditionalPropertiesDictionary_NullMetadata_ReturnsNull()
+ {
+ var message = new Message
+ {
+ MessageId = "test-id",
+ Role = MessageRole.User,
+ Parts = new List { new TextPart { Text = "Test" } },
+ Metadata = null
+ };
+
+ var result = new List { message }.ToChatMessages();
+
+ var chatMessage = result.First();
+ Assert.Null(chatMessage.AdditionalProperties);
+ }
+
+ [Fact]
+ public void ToAdditionalPropertiesDictionary_EmptyMetadata_ReturnsNull()
+ {
+ var message = new Message
+ {
+ MessageId = "test-id",
+ Role = MessageRole.User,
+ Parts = new List { new TextPart { Text = "Test" } },
+ Metadata = new Dictionary()
+ };
+
+ var result = new List { message }.ToChatMessages();
+
+ var chatMessage = result.First();
+ Assert.Null(chatMessage.AdditionalProperties);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.A2A.Tests/Microsoft.Extensions.AI.Agents.Hosting.A2A.Tests.csproj b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.A2A.Tests/Microsoft.Extensions.AI.Agents.Hosting.A2A.Tests.csproj
new file mode 100644
index 0000000000..ddc94e3b54
--- /dev/null
+++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.A2A.Tests/Microsoft.Extensions.AI.Agents.Hosting.A2A.Tests.csproj
@@ -0,0 +1,17 @@
+
+
+
+ $(ProjectsCoreTargetFrameworks)
+ $(NoWarn);NU1504
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file