From 3b895c476d028cb538dd49846c34374925445085 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Wed, 7 Jan 2026 22:48:28 +0000 Subject: [PATCH 1/7] map additional props from agent run options to a2a request metadata --- .../src/Microsoft.Agents.AI.A2A/A2AAgent.cs | 20 +- .../Extensions/A2AMetadataExtensions.cs | 3 + ...dditionalPropertiesDictionaryExtensions.cs | 38 +++ .../AIAgentExtensions.cs | 4 +- .../Converters/A2AMetadataExtensions.cs | 36 +++ ...dditionalPropertiesDictionaryExtensions.cs | 39 ++++ .../A2AAgentTests.cs | 136 +++++++++++ ...onalPropertiesDictionaryExtensionsTests.cs | 164 +++++++++++++ .../AIAgentExtensionsTests.cs | 218 ++++++++++++++++++ 9 files changed, 651 insertions(+), 7 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.A2A/Extensions/AdditionalPropertiesDictionaryExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/A2AMetadataExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/AdditionalPropertiesDictionaryExtensions.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/AdditionalPropertiesDictionaryExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs index cf88a89177..4e70feef4a 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs @@ -84,9 +84,13 @@ protected override async Task RunCoreAsync(IEnumerable RunCoreStreami // a2aSseEvents = this._a2aClient.SubscribeToTaskAsync(token.TaskId, cancellationToken).ConfigureAwait(false); } - var a2aMessage = CreateA2AMessage(typedThread, messages); + MessageSendParams sendParams = new() + { + Message = CreateA2AMessage(typedThread, messages), + Metadata = options?.AdditionalProperties.ToA2AMetadata() + }; - a2aSseEvents = this._a2aClient.SendMessageStreamingAsync(new MessageSendParams { Message = a2aMessage }, cancellationToken).ConfigureAwait(false); + a2aSseEvents = this._a2aClient.SendMessageStreamingAsync(sendParams, cancellationToken).ConfigureAwait(false); this._logger.LogAgentChatClientInvokedAgent(nameof(RunStreamingAsync), this.Id, this.Name); @@ -198,10 +206,10 @@ protected override async IAsyncEnumerable RunCoreStreami protected override string? IdCore => this._id; /// - public override string? Name => this._name ?? base.Name; + public override string? Name => this._name; /// - public override string? Description => this._description ?? base.Description; + public override string? Description => this._description; private A2AAgentThread GetA2AThread(AgentThread? thread, AgentRunOptions? options) { diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AMetadataExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AMetadataExtensions.cs index c0dedbd541..3c81c6abe8 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AMetadataExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AMetadataExtensions.cs @@ -14,6 +14,9 @@ internal static class A2AMetadataExtensions /// /// Converts a dictionary of metadata to an . /// + /// + /// This method can be replaced by the one from A2A SDK once it is public. + /// /// The metadata dictionary to convert. /// The converted , or null if the input is null or empty. internal static AdditionalPropertiesDictionary? ToAdditionalProperties(this Dictionary? metadata) diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/AdditionalPropertiesDictionaryExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/AdditionalPropertiesDictionaryExtensions.cs new file mode 100644 index 0000000000..31e316ba63 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/AdditionalPropertiesDictionaryExtensions.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.Agents.AI; + +namespace Microsoft.Extensions.AI; + +/// +/// Extension methods for AdditionalPropertiesDictionary. +/// +internal static class AdditionalPropertiesDictionaryExtensions +{ + /// + /// Converts an to a dictionary of values suitable for A2A metadata. + /// + /// + /// This method can be replaced by the one from A2A SDK once it is available. + /// + /// The additional properties dictionary to convert, or null. + /// A dictionary of JSON elements representing the metadata, or null if the input is null or empty. + internal static Dictionary? ToA2AMetadata(this AdditionalPropertiesDictionary? additionalProperties) + { + if (additionalProperties is not { Count: > 0 }) + { + return null; + } + + var metadata = new Dictionary(); + + foreach (var kvp in additionalProperties) + { + metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); + } + + return metadata; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs index c54af66bb8..0ae69eab92 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs @@ -47,6 +47,7 @@ async Task OnMessageReceivedAsync(MessageSendParams messageSendPara var response = await hostAgent.RunAsync( messageSendParams.ToChatMessages(), thread: thread, + options: messageSendParams.Metadata is not { Count: > 0 } ? null : new AgentRunOptions { AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() }, cancellationToken: cancellationToken).ConfigureAwait(false); await hostAgent.SaveThreadAsync(contextId, thread, cancellationToken).ConfigureAwait(false); @@ -56,7 +57,8 @@ async Task OnMessageReceivedAsync(MessageSendParams messageSendPara MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"), ContextId = contextId, Role = MessageRole.Agent, - Parts = parts + Parts = parts, + Metadata = response.AdditionalProperties?.ToA2AMetadata() }; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/A2AMetadataExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/A2AMetadataExtensions.cs new file mode 100644 index 0000000000..010264bb65 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/A2AMetadataExtensions.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.A2A.Converters; + +/// +/// Extension methods for A2A metadata dictionary. +/// +internal static class A2AMetadataExtensions +{ + /// + /// Converts a dictionary of metadata to an . + /// + /// + /// This method can be replaced by the one from A2A SDK once it is public. + /// + /// The metadata dictionary to convert. + /// The converted , or null if the input is null or empty. + internal static AdditionalPropertiesDictionary? ToAdditionalProperties(this Dictionary? metadata) + { + if (metadata is not { 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.Agents.AI.Hosting.A2A/Converters/AdditionalPropertiesDictionaryExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/AdditionalPropertiesDictionaryExtensions.cs new file mode 100644 index 0000000000..6d86476d60 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/AdditionalPropertiesDictionaryExtensions.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; +using A2A; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.A2A.Converters; + +/// +/// Extension methods for AdditionalPropertiesDictionary. +/// +internal static class AdditionalPropertiesDictionaryExtensions +{ + /// + /// Converts an to a dictionary of values suitable for A2A metadata. + /// + /// + /// This method can be replaced by the one from A2A SDK once it is available. + /// + /// The additional properties dictionary to convert, or null. + /// A dictionary of JSON elements representing the metadata, or null if the input is null or empty. + internal static Dictionary? ToA2AMetadata(this AdditionalPropertiesDictionary? additionalProperties) + { + if (additionalProperties is not { Count: > 0 }) + { + return null; + } + + var metadata = new Dictionary(); + + foreach (var kvp in additionalProperties) + { + metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); + } + + return metadata; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs index 0b491fb303..5dfba68192 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs @@ -832,6 +832,142 @@ await Assert.ThrowsAsync(async () => }); } + [Fact] + public async Task RunAsync_WithAdditionalProperties_PropagatesThemAsMetadataToMessageSendParamsAsync() + { + // Arrange + this._handler.ResponseToReturn = new AgentMessage + { + MessageId = "response-123", + Role = MessageRole.Agent, + Parts = [new TextPart { Text = "Response" }] + }; + + var inputMessages = new List + { + new(ChatRole.User, "Test message") + }; + + var options = new AgentRunOptions + { + AdditionalProperties = new() + { + { "key1", "value1" }, + { "key2", 42 }, + { "key3", true } + } + }; + + // Act + await this._agent.RunAsync(inputMessages, null, options); + + // Assert + Assert.NotNull(this._handler.CapturedMessageSendParams); + Assert.NotNull(this._handler.CapturedMessageSendParams.Metadata); + Assert.Equal("value1", this._handler.CapturedMessageSendParams.Metadata["key1"].GetString()); + Assert.Equal(42, this._handler.CapturedMessageSendParams.Metadata["key2"].GetInt32()); + Assert.True(this._handler.CapturedMessageSendParams.Metadata["key3"].GetBoolean()); + } + + [Fact] + public async Task RunAsync_WithNullAdditionalProperties_DoesNotSetMetadataAsync() + { + // Arrange + this._handler.ResponseToReturn = new AgentMessage + { + MessageId = "response-123", + Role = MessageRole.Agent, + Parts = [new TextPart { Text = "Response" }] + }; + + var inputMessages = new List + { + new(ChatRole.User, "Test message") + }; + + var options = new AgentRunOptions + { + AdditionalProperties = null + }; + + // Act + await this._agent.RunAsync(inputMessages, null, options); + + // Assert + Assert.NotNull(this._handler.CapturedMessageSendParams); + Assert.Null(this._handler.CapturedMessageSendParams.Metadata); + } + + [Fact] + public async Task RunStreamingAsync_WithAdditionalProperties_PropagatesThemAsMetadataToMessageSendParamsAsync() + { + // Arrange + this._handler.StreamingResponseToReturn = new AgentMessage + { + MessageId = "stream-123", + Role = MessageRole.Agent, + Parts = [new TextPart { Text = "Streaming response" }] + }; + + var inputMessages = new List + { + new(ChatRole.User, "Test streaming message") + }; + + var options = new AgentRunOptions + { + AdditionalProperties = new() + { + { "streamKey1", "streamValue1" }, + { "streamKey2", 100 }, + { "streamKey3", false } + } + }; + + // Act + await foreach (var _ in this._agent.RunStreamingAsync(inputMessages, null, options)) + { + } + + // Assert + Assert.NotNull(this._handler.CapturedMessageSendParams); + Assert.NotNull(this._handler.CapturedMessageSendParams.Metadata); + Assert.Equal("streamValue1", this._handler.CapturedMessageSendParams.Metadata["streamKey1"].GetString()); + Assert.Equal(100, this._handler.CapturedMessageSendParams.Metadata["streamKey2"].GetInt32()); + Assert.False(this._handler.CapturedMessageSendParams.Metadata["streamKey3"].GetBoolean()); + } + + [Fact] + public async Task RunStreamingAsync_WithNullAdditionalProperties_DoesNotSetMetadataAsync() + { + // Arrange + this._handler.StreamingResponseToReturn = new AgentMessage + { + MessageId = "stream-123", + Role = MessageRole.Agent, + Parts = [new TextPart { Text = "Streaming response" }] + }; + + var inputMessages = new List + { + new(ChatRole.User, "Test streaming message") + }; + + var options = new AgentRunOptions + { + AdditionalProperties = null + }; + + // Act + await foreach (var _ in this._agent.RunStreamingAsync(inputMessages, null, options)) + { + } + + // Assert + Assert.NotNull(this._handler.CapturedMessageSendParams); + Assert.Null(this._handler.CapturedMessageSendParams.Metadata); + } + [Fact] public async Task RunAsync_WithInvalidThreadType_ThrowsInvalidOperationExceptionAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/AdditionalPropertiesDictionaryExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/AdditionalPropertiesDictionaryExtensionsTests.cs new file mode 100644 index 0000000000..49cb66a5b2 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/AdditionalPropertiesDictionaryExtensionsTests.cs @@ -0,0 +1,164 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.A2A.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class AdditionalPropertiesDictionaryExtensionsTests +{ + [Fact] + public void ToA2AMetadata_WithNullAdditionalProperties_ReturnsNull() + { + // Arrange + AdditionalPropertiesDictionary? additionalProperties = null; + + // Act + Dictionary? result = additionalProperties.ToA2AMetadata(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void ToA2AMetadata_WithEmptyAdditionalProperties_ReturnsNull() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = []; + + // Act + Dictionary? result = additionalProperties.ToA2AMetadata(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void ToA2AMetadata_WithStringValue_ReturnsMetadataWithJsonElement() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new() + { + { "stringKey", "stringValue" } + }; + + // Act + Dictionary? result = additionalProperties.ToA2AMetadata(); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + Assert.True(result.ContainsKey("stringKey")); + Assert.Equal("stringValue", result["stringKey"].GetString()); + } + + [Fact] + public void ToA2AMetadata_WithNumericValue_ReturnsMetadataWithJsonElement() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new() + { + { "numberKey", 42 } + }; + + // Act + Dictionary? result = additionalProperties.ToA2AMetadata(); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + Assert.True(result.ContainsKey("numberKey")); + Assert.Equal(42, result["numberKey"].GetInt32()); + } + + [Fact] + public void ToA2AMetadata_WithBooleanValue_ReturnsMetadataWithJsonElement() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new() + { + { "booleanKey", true } + }; + + // Act + Dictionary? result = additionalProperties.ToA2AMetadata(); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + Assert.True(result.ContainsKey("booleanKey")); + Assert.True(result["booleanKey"].GetBoolean()); + } + + [Fact] + public void ToA2AMetadata_WithMultipleProperties_ReturnsMetadataWithAllProperties() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new() + { + { "stringKey", "stringValue" }, + { "numberKey", 42 }, + { "booleanKey", true } + }; + + // Act + Dictionary? result = additionalProperties.ToA2AMetadata(); + + // Assert + Assert.NotNull(result); + Assert.Equal(3, result.Count); + + Assert.True(result.ContainsKey("stringKey")); + Assert.Equal("stringValue", result["stringKey"].GetString()); + + Assert.True(result.ContainsKey("numberKey")); + Assert.Equal(42, result["numberKey"].GetInt32()); + + Assert.True(result.ContainsKey("booleanKey")); + Assert.True(result["booleanKey"].GetBoolean()); + } + + [Fact] + public void ToA2AMetadata_WithArrayValue_ReturnsMetadataWithJsonElement() + { + // Arrange + int[] arrayValue = [1, 2, 3]; + AdditionalPropertiesDictionary additionalProperties = new() + { + { "arrayKey", arrayValue } + }; + + // Act + Dictionary? result = additionalProperties.ToA2AMetadata(); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + Assert.True(result.ContainsKey("arrayKey")); + Assert.Equal(JsonValueKind.Array, result["arrayKey"].ValueKind); + Assert.Equal(3, result["arrayKey"].GetArrayLength()); + } + + [Fact] + public void ToA2AMetadata_WithNullValue_ReturnsMetadataWithNullJsonElement() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new() + { + { "nullKey", null! } + }; + + // Act + Dictionary? result = additionalProperties.ToA2AMetadata(); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + Assert.True(result.ContainsKey("nullKey")); + Assert.Equal(JsonValueKind.Null, result["nullKey"].ValueKind); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs new file mode 100644 index 0000000000..0d5b895974 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs @@ -0,0 +1,218 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using A2A; +using Microsoft.Extensions.AI; +using Moq; +using Moq.Protected; + +namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class AIAgentExtensionsTests +{ + /// + /// Verifies that when messageSendParams.Metadata is null, the options passed to RunAsync are null. + /// + [Fact] + public async Task MapA2A_WhenMetadataIsNull_PassesNullOptionsToRunAsync() + { + // Arrange + AgentRunOptions? capturedOptions = null; + ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A(); + + // Act + await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + { + Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }, + Metadata = null + }); + + // Assert + Assert.Null(capturedOptions); + } + + /// + /// Verifies that when messageSendParams.Metadata has values, the options.AdditionalProperties contains the converted values. + /// + [Fact] + public async Task MapA2A_WhenMetadataHasValues_PassesOptionsWithAdditionalPropertiesToRunAsync() + { + // Arrange + AgentRunOptions? capturedOptions = null; + ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A(); + + // Act + await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + { + Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }, + Metadata = new Dictionary + { + ["key1"] = JsonSerializer.SerializeToElement("value1"), + ["key2"] = JsonSerializer.SerializeToElement(42) + } + }); + + // Assert + Assert.NotNull(capturedOptions); + Assert.NotNull(capturedOptions.AdditionalProperties); + Assert.Equal(2, capturedOptions.AdditionalProperties.Count); + Assert.True(capturedOptions.AdditionalProperties.ContainsKey("key1")); + Assert.True(capturedOptions.AdditionalProperties.ContainsKey("key2")); + } + + /// + /// Verifies that when messageSendParams.Metadata is an empty dictionary, the options passed to RunAsync is null + /// because the ToAdditionalProperties extension method returns null for empty dictionaries. + /// + [Fact] + public async Task MapA2A_WhenMetadataIsEmptyDictionary_PassesNullOptionsToRunAsync() + { + // Arrange + AgentRunOptions? capturedOptions = null; + ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A(); + + // Act + await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + { + Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }, + Metadata = [] + }); + + // Assert + Assert.Null(capturedOptions); + } + + /// + /// Verifies that when the agent response has AdditionalProperties, the returned AgentMessage.Metadata contains the converted values. + /// + [Fact] + public async Task MapA2A_WhenResponseHasAdditionalProperties_ReturnsAgentMessageWithMetadataAsync() + { + // Arrange + AdditionalPropertiesDictionary additionalProps = new() + { + ["responseKey1"] = "responseValue1", + ["responseKey2"] = 123 + }; + AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")]) + { + AdditionalProperties = additionalProps + }; + ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); + + // Act + A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + { + Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + }); + + // Assert + AgentMessage agentMessage = Assert.IsType(a2aResponse); + Assert.NotNull(agentMessage.Metadata); + Assert.Equal(2, agentMessage.Metadata.Count); + Assert.True(agentMessage.Metadata.ContainsKey("responseKey1")); + Assert.True(agentMessage.Metadata.ContainsKey("responseKey2")); + Assert.Equal("responseValue1", agentMessage.Metadata["responseKey1"].GetString()); + Assert.Equal(123, agentMessage.Metadata["responseKey2"].GetInt32()); + } + + /// + /// Verifies that when the agent response has null AdditionalProperties, the returned AgentMessage.Metadata is null. + /// + [Fact] + public async Task MapA2A_WhenResponseHasNullAdditionalProperties_ReturnsAgentMessageWithNullMetadataAsync() + { + // Arrange + AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")]) + { + AdditionalProperties = null + }; + ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); + + // Act + A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + { + Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + }); + + // Assert + AgentMessage agentMessage = Assert.IsType(a2aResponse); + Assert.Null(agentMessage.Metadata); + } + + /// + /// Verifies that when the agent response has empty AdditionalProperties, the returned AgentMessage.Metadata is null. + /// + [Fact] + public async Task MapA2A_WhenResponseHasEmptyAdditionalProperties_ReturnsAgentMessageWithNullMetadataAsync() + { + // Arrange + AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")]) + { + AdditionalProperties = [] + }; + ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); + + // Act + A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + { + Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + }); + + // Assert + AgentMessage agentMessage = Assert.IsType(a2aResponse); + Assert.Null(agentMessage.Metadata); + } + + private static Mock CreateAgentMock(Action optionsCallback) + { + Mock agentMock = new() { CallBase = true }; + agentMock.SetupGet(x => x.Name).Returns("TestAgent"); + agentMock.Setup(x => x.GetNewThread()).Returns(new TestAgentThread()); + agentMock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback, AgentThread?, AgentRunOptions?, CancellationToken>( + (_, _, options, _) => optionsCallback(options)) + .ReturnsAsync(new AgentRunResponse([new ChatMessage(ChatRole.Assistant, "Test response")])); + + return agentMock; + } + + private static Mock CreateAgentMockWithResponse(AgentRunResponse response) + { + Mock agentMock = new() { CallBase = true }; + agentMock.SetupGet(x => x.Name).Returns("TestAgent"); + agentMock.Setup(x => x.GetNewThread()).Returns(new TestAgentThread()); + agentMock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(response); + + return agentMock; + } + + private static async Task InvokeOnMessageReceivedAsync(ITaskManager taskManager, MessageSendParams messageSendParams) + { + Func>? handler = taskManager.OnMessageReceived; + Assert.NotNull(handler); + return await handler.Invoke(messageSendParams, CancellationToken.None); + } + + private sealed class TestAgentThread : AgentThread; +} From 889777421b8b5f2c40a9fc902257e477b0f8dd5b Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Thu, 8 Jan 2026 09:52:23 +0000 Subject: [PATCH 2/7] small touches --- dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs | 4 ++-- .../src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs index 4e70feef4a..839c95e1cb 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs @@ -87,7 +87,7 @@ protected override async Task RunCoreAsync(IEnumerable RunCoreStreami MessageSendParams sendParams = new() { Message = CreateA2AMessage(typedThread, messages), - Metadata = options?.AdditionalProperties.ToA2AMetadata() + Metadata = options?.AdditionalProperties?.ToA2AMetadata() }; a2aSseEvents = this._a2aClient.SendMessageStreamingAsync(sendParams, cancellationToken).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs index 0ae69eab92..499d724b1a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs @@ -43,11 +43,14 @@ async Task OnMessageReceivedAsync(MessageSendParams messageSendPara { var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N"); var thread = await hostAgent.GetOrCreateThreadAsync(contextId, cancellationToken).ConfigureAwait(false); + var options = messageSendParams.Metadata is not { Count: > 0 } + ? null + : new AgentRunOptions { AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() }; var response = await hostAgent.RunAsync( messageSendParams.ToChatMessages(), thread: thread, - options: messageSendParams.Metadata is not { Count: > 0 } ? null : new AgentRunOptions { AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() }, + options: options, cancellationToken: cancellationToken).ConfigureAwait(false); await hostAgent.SaveThreadAsync(contextId, thread, cancellationToken).ConfigureAwait(false); From 095a2c76d6c486c52e3b331c0e0feb0545a4f4f4 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Thu, 8 Jan 2026 10:59:12 +0000 Subject: [PATCH 3/7] add unit tests for new extension methods --- ...onalPropertiesDictionaryExtensionsTests.cs | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/AdditionalPropertiesDictionaryExtensionsTests.cs diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/AdditionalPropertiesDictionaryExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/AdditionalPropertiesDictionaryExtensionsTests.cs new file mode 100644 index 0000000000..e8fc21186b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/AdditionalPropertiesDictionaryExtensionsTests.cs @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.Extensions.AI; +using Microsoft.Agents.AI.Hosting.A2A.Converters; + +namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests.Converters; + +/// +/// Unit tests for the class. +/// +public sealed class AdditionalPropertiesDictionaryExtensionsTests +{ + [Fact] + public void ToA2AMetadata_WithNullAdditionalProperties_ReturnsNull() + { + // Arrange + AdditionalPropertiesDictionary? additionalProperties = null; + + // Act + Dictionary? result = additionalProperties.ToA2AMetadata(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void ToA2AMetadata_WithEmptyAdditionalProperties_ReturnsNull() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = []; + + // Act + Dictionary? result = additionalProperties.ToA2AMetadata(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void ToA2AMetadata_WithStringValue_ReturnsMetadataWithJsonElement() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new() + { + { "stringKey", "stringValue" } + }; + + // Act + Dictionary? result = additionalProperties.ToA2AMetadata(); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + Assert.True(result.ContainsKey("stringKey")); + Assert.Equal("stringValue", result["stringKey"].GetString()); + } + + [Fact] + public void ToA2AMetadata_WithNumericValue_ReturnsMetadataWithJsonElement() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new() + { + { "numberKey", 42 } + }; + + // Act + Dictionary? result = additionalProperties.ToA2AMetadata(); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + Assert.True(result.ContainsKey("numberKey")); + Assert.Equal(42, result["numberKey"].GetInt32()); + } + + [Fact] + public void ToA2AMetadata_WithBooleanValue_ReturnsMetadataWithJsonElement() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new() + { + { "booleanKey", true } + }; + + // Act + Dictionary? result = additionalProperties.ToA2AMetadata(); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + Assert.True(result.ContainsKey("booleanKey")); + Assert.True(result["booleanKey"].GetBoolean()); + } + + [Fact] + public void ToA2AMetadata_WithMultipleProperties_ReturnsMetadataWithAllProperties() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new() + { + { "stringKey", "stringValue" }, + { "numberKey", 42 }, + { "booleanKey", true } + }; + + // Act + Dictionary? result = additionalProperties.ToA2AMetadata(); + + // Assert + Assert.NotNull(result); + Assert.Equal(3, result.Count); + + Assert.True(result.ContainsKey("stringKey")); + Assert.Equal("stringValue", result["stringKey"].GetString()); + + Assert.True(result.ContainsKey("numberKey")); + Assert.Equal(42, result["numberKey"].GetInt32()); + + Assert.True(result.ContainsKey("booleanKey")); + Assert.True(result["booleanKey"].GetBoolean()); + } + + [Fact] + public void ToA2AMetadata_WithArrayValue_ReturnsMetadataWithJsonElement() + { + // Arrange + int[] arrayValue = [1, 2, 3]; + AdditionalPropertiesDictionary additionalProperties = new() + { + { "arrayKey", arrayValue } + }; + + // Act + Dictionary? result = additionalProperties.ToA2AMetadata(); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + Assert.True(result.ContainsKey("arrayKey")); + Assert.Equal(JsonValueKind.Array, result["arrayKey"].ValueKind); + Assert.Equal(3, result["arrayKey"].GetArrayLength()); + } + + [Fact] + public void ToA2AMetadata_WithNullValue_ReturnsMetadataWithNullJsonElement() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new() + { + { "nullKey", null! } + }; + + // Act + Dictionary? result = additionalProperties.ToA2AMetadata(); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + Assert.True(result.ContainsKey("nullKey")); + Assert.Equal(JsonValueKind.Null, result["nullKey"].ValueKind); + } +} From 7ed6aa917b4278e10c1b701dce364f822cc9880f Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Thu, 8 Jan 2026 11:09:07 +0000 Subject: [PATCH 4/7] Sort using --- .../Converters/AdditionalPropertiesDictionaryExtensionsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/AdditionalPropertiesDictionaryExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/AdditionalPropertiesDictionaryExtensionsTests.cs index e8fc21186b..e445a45b49 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/AdditionalPropertiesDictionaryExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/AdditionalPropertiesDictionaryExtensionsTests.cs @@ -2,8 +2,8 @@ using System.Collections.Generic; using System.Text.Json; -using Microsoft.Extensions.AI; using Microsoft.Agents.AI.Hosting.A2A.Converters; +using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests.Converters; From f6068c4984d30c7366af4896a418d689361f5b36 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Thu, 8 Jan 2026 11:29:53 +0000 Subject: [PATCH 5/7] add unit test --- .../A2AAgentTests.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs index 5dfba68192..236ae7b332 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs @@ -832,6 +832,38 @@ await Assert.ThrowsAsync(async () => }); } + [Fact] + public async Task RunAsync_WithAgentMessageResponseMetadata_ReturnsMetadataAsAdditionalPropertiesAsync() + { + // Arrange + this._handler.ResponseToReturn = new AgentMessage + { + MessageId = "response-123", + Role = MessageRole.Agent, + Parts = [new TextPart { Text = "Response with metadata" }], + Metadata = new Dictionary + { + { "responseKey1", JsonSerializer.SerializeToElement("responseValue1") }, + { "responseCount", JsonSerializer.SerializeToElement(99) } + } + }; + + var inputMessages = new List + { + new(ChatRole.User, "Test message") + }; + + // Act + var result = await this._agent.RunAsync(inputMessages); + + // Assert + Assert.NotNull(result.AdditionalProperties); + Assert.NotNull(result.AdditionalProperties["responseKey1"]); + Assert.Equal("responseValue1", ((JsonElement)result.AdditionalProperties["responseKey1"]!).GetString()); + Assert.NotNull(result.AdditionalProperties["responseCount"]); + Assert.Equal(99, ((JsonElement)result.AdditionalProperties["responseCount"]!).GetInt32()); + } + [Fact] public async Task RunAsync_WithAdditionalProperties_PropagatesThemAsMetadataToMessageSendParamsAsync() { From 99b0affd4b4ffc3bab677d5640c28d836f1ae5c4 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Thu, 8 Jan 2026 12:42:39 +0000 Subject: [PATCH 6/7] add additiona unit tests --- ...onalPropertiesDictionaryExtensionsTests.cs | 22 +++++++++++++++++++ ...onalPropertiesDictionaryExtensionsTests.cs | 22 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/AdditionalPropertiesDictionaryExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/AdditionalPropertiesDictionaryExtensionsTests.cs index 49cb66a5b2..4972b8857f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/AdditionalPropertiesDictionaryExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/AdditionalPropertiesDictionaryExtensionsTests.cs @@ -161,4 +161,26 @@ public void ToA2AMetadata_WithNullValue_ReturnsMetadataWithNullJsonElement() Assert.True(result.ContainsKey("nullKey")); Assert.Equal(JsonValueKind.Null, result["nullKey"].ValueKind); } + + [Fact] + public void ToA2AMetadata_WithJsonElementValue_ReturnsMetadataWithJsonElement() + { + // Arrange + JsonElement jsonElement = JsonSerializer.SerializeToElement(new { name = "test", value = 123 }); + AdditionalPropertiesDictionary additionalProperties = new() + { + { "jsonElementKey", jsonElement } + }; + + // Act + Dictionary? result = additionalProperties.ToA2AMetadata(); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + Assert.True(result.ContainsKey("jsonElementKey")); + Assert.Equal(JsonValueKind.Object, result["jsonElementKey"].ValueKind); + Assert.Equal("test", result["jsonElementKey"].GetProperty("name").GetString()); + Assert.Equal(123, result["jsonElementKey"].GetProperty("value").GetInt32()); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/AdditionalPropertiesDictionaryExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/AdditionalPropertiesDictionaryExtensionsTests.cs index e445a45b49..e0c8c4e96b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/AdditionalPropertiesDictionaryExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/AdditionalPropertiesDictionaryExtensionsTests.cs @@ -162,4 +162,26 @@ public void ToA2AMetadata_WithNullValue_ReturnsMetadataWithNullJsonElement() Assert.True(result.ContainsKey("nullKey")); Assert.Equal(JsonValueKind.Null, result["nullKey"].ValueKind); } + + [Fact] + public void ToA2AMetadata_WithJsonElementValue_ReturnsMetadataWithJsonElement() + { + // Arrange + JsonElement jsonElement = JsonSerializer.SerializeToElement(new { name = "test", value = 123 }); + AdditionalPropertiesDictionary additionalProperties = new() + { + { "jsonElementKey", jsonElement } + }; + + // Act + Dictionary? result = additionalProperties.ToA2AMetadata(); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + Assert.True(result.ContainsKey("jsonElementKey")); + Assert.Equal(JsonValueKind.Object, result["jsonElementKey"].ValueKind); + Assert.Equal("test", result["jsonElementKey"].GetProperty("name").GetString()); + Assert.Equal(123, result["jsonElementKey"].GetProperty("value").GetInt32()); + } } From 0f79ac450bc48798ea6e3ac9dd00750430ea9c9a Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Thu, 8 Jan 2026 12:59:11 +0000 Subject: [PATCH 7/7] special case json element to avoid unnecessary serialization --- .../Extensions/AdditionalPropertiesDictionaryExtensions.cs | 6 ++++++ .../Converters/AdditionalPropertiesDictionaryExtensions.cs | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/AdditionalPropertiesDictionaryExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/AdditionalPropertiesDictionaryExtensions.cs index 31e316ba63..a3340d2ca8 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/AdditionalPropertiesDictionaryExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/AdditionalPropertiesDictionaryExtensions.cs @@ -30,6 +30,12 @@ internal static class AdditionalPropertiesDictionaryExtensions foreach (var kvp in additionalProperties) { + if (kvp.Value is JsonElement) + { + metadata[kvp.Key] = (JsonElement)kvp.Value!; + continue; + } + metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/AdditionalPropertiesDictionaryExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/AdditionalPropertiesDictionaryExtensions.cs index 6d86476d60..d46ef72d1f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/AdditionalPropertiesDictionaryExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/AdditionalPropertiesDictionaryExtensions.cs @@ -31,6 +31,12 @@ internal static class AdditionalPropertiesDictionaryExtensions foreach (var kvp in additionalProperties) { + if (kvp.Value is JsonElement) + { + metadata[kvp.Key] = (JsonElement)kvp.Value!; + continue; + } + metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); }