.NET: Fix A2A conversion routines to ignore unknown content types instead of throwing exceptions (#1154)

* Initial plan

* Update A2A conversion routines to ignore unknown content types

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

* Fix dotnet format

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Co-authored-by: Stephen Toub <stoub@microsoft.com>
Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
This commit is contained in:
Copilot
2025-10-06 12:29:43 +02:00
committed by GitHub
Unverified
parent 4c5c6d0f98
commit 62854197aa
9 changed files with 142 additions and 33 deletions
@@ -159,7 +159,7 @@ internal sealed class A2AAgent : AIAgent
RawRepresentation = message,
Role = ChatRole.Assistant,
MessageId = message.MessageId,
Contents = [.. message.Parts.Select(part => part.ToAIContent())],
Contents = [.. message.Parts.Select(part => part.ToAIContent()).OfType<AIContent>()],
AdditionalProperties = message.Metadata.ToAdditionalProperties(),
};
}
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using A2A;
@@ -22,7 +21,11 @@ internal static class A2AAIContentExtensions
foreach (var content in contents)
{
(parts ??= []).Add(content.ToA2APart());
var part = content.ToA2APart();
if (part is not null)
{
(parts ??= []).Add(part);
}
}
return parts;
@@ -32,12 +35,13 @@ internal static class A2AAIContentExtensions
/// Converts a <see cref="AIContent"/> to a <see cref="Part"/> object."/>
/// </summary>
/// <param name="content">AI content to convert.</param>
/// <returns>The corresponding A2A <see cref="Part"/> object.</returns>
internal static Part ToA2APart(this AIContent content) =>
/// <returns>The corresponding A2A <see cref="Part"/> object, or null if the content type is not supported.</returns>
internal static Part? ToA2APart(this AIContent content) =>
content switch
{
TextContent textContent => new TextPart { Text = textContent.Text },
HostedFileContent hostedFileContent => new FilePart { File = new FileWithUri { Uri = hostedFileContent.FileId } },
_ => throw new NotSupportedException($"Unsupported content type: {content.GetType().Name}."),
// Ignore unknown content types (FunctionCallContent, FunctionResultContent, etc.)
_ => null,
};
}
@@ -16,7 +16,11 @@ internal static class A2AArtifactExtensions
foreach (var part in artifact.Parts)
{
(aiContents ??= []).Add(part.ToAIContent());
var content = part.ToAIContent();
if (content is not null)
{
(aiContents ??= []).Add(content);
}
}
return new ChatMessage(ChatRole.Assistant, aiContents)
@@ -16,7 +16,11 @@ internal static class A2AMessageExtensions
foreach (var part in message.Parts)
{
(aiContents ??= []).Add(part.ToAIContent());
var content = part.ToAIContent();
if (content is not null)
{
(aiContents ??= []).Add(content);
}
}
return new ChatMessage(ChatRole.Assistant, aiContents)
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.AI;
namespace A2A;
@@ -14,8 +13,8 @@ internal static class A2APartExtensions
/// Converts an A2A <see cref="Part"/> to an <see cref="AIContent"/>.
/// </summary>
/// <param name="part">The A2A part to convert.</param>
/// <returns>The corresponding <see cref="AIContent"/>.</returns>
internal static AIContent ToAIContent(this Part part) =>
/// <returns>The corresponding <see cref="AIContent"/>, or null if the part type is not supported.</returns>
internal static AIContent? ToAIContent(this Part part) =>
part switch
{
TextPart textPart => new TextContent(textPart.Text)
@@ -30,6 +29,7 @@ internal static class A2APartExtensions
AdditionalProperties = filePart.Metadata.ToAdditionalProperties()
},
_ => throw new NotSupportedException($"Part type '{part.GetType().Name}' is not supported.")
// Ignore unknown part types (DataPart, etc.)
_ => null
};
}
@@ -160,7 +160,8 @@ internal static class MessageConverter
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.")
// Ignore unknown content types (FilePart, DataPart, etc.)
_ => null
};
/// <summary>
@@ -234,7 +235,8 @@ internal static class MessageConverter
{
Text = textContent.Text
},
_ => throw new NotSupportedException($"Content type '{content.GetType().Name}' is not supported.")
// Ignore unknown content types (FunctionCallContent, FunctionResultContent, etc.)
_ => null
};
private static AdditionalPropertiesDictionary? ToAdditionalPropertiesDictionary(this Dictionary<string, JsonElement> metadata)
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using A2A;
using Microsoft.Extensions.AI;
@@ -49,14 +48,16 @@ public sealed class A2AAIContentExtensionsTests
}
[Fact]
public void ToA2APart_WithUnsupportedContentType_ThrowsNotSupportedException()
public void ToA2APart_WithUnsupportedContentType_ReturnsNull()
{
// Arrange
var unsupportedContent = new MockAIContent();
// Act & Assert
var exception = Assert.Throws<NotSupportedException>(unsupportedContent.ToA2APart);
Assert.Equal("Unsupported content type: MockAIContent.", exception.Message);
// Act
var result = unsupportedContent.ToA2APart();
// Assert
Assert.Null(result);
}
[Fact]
@@ -106,6 +107,37 @@ public sealed class A2AAIContentExtensionsTests
Assert.Equal("https://example.com/file2.txt", secondFileWithUri.Uri);
}
[Fact]
public void ToA2AParts_WithMixedSupportedAndUnsupportedContent_IgnoresUnsupportedContent()
{
// Arrange
var contents = new List<AIContent>
{
new TextContent("First text"),
new MockAIContent(), // Unsupported - should be ignored
new HostedFileContent("https://example.com/file.txt"),
new MockAIContent(), // Unsupported - should be ignored
new TextContent("Second text")
};
// Act
var result = contents.ToA2AParts();
// Assert
Assert.NotNull(result);
Assert.Equal(3, result.Count);
var firstTextPart = Assert.IsType<TextPart>(result[0]);
Assert.Equal("First text", firstTextPart.Text);
var filePart = Assert.IsType<FilePart>(result[1]);
var fileWithUri = Assert.IsType<FileWithUri>(filePart.File);
Assert.Equal("https://example.com/file.txt", fileWithUri.Uri);
var secondTextPart = Assert.IsType<TextPart>(result[2]);
Assert.Equal("Second text", secondTextPart.Text);
}
// Mock class for testing unsupported scenarios
private sealed class MockAIContent : AIContent;
}
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using A2A;
@@ -80,14 +79,16 @@ public sealed class A2APartExtensionsTests
}
[Fact]
public void ToAIContent_WithCustomPartType_ThrowsNotSupportedException()
public void ToAIContent_WithCustomPartType_ReturnsNull()
{
// Arrange
var customPart = new MockPart();
// Act & Assert
var exception = Assert.Throws<NotSupportedException>(customPart.ToAIContent);
Assert.Equal("Part type 'MockPart' is not supported.", exception.Message);
// Act
var result = customPart.ToAIContent();
// Assert
Assert.Null(result);
}
// Mock class for testing unsupported scenarios
@@ -228,13 +228,16 @@ public class MessageConverterTests
}
[Fact]
public void ToA2AMessage_ChatMessageWithUnsupportedContent_ThrowsNotSupportedException()
public void ToA2AMessage_ChatMessageWithUnsupportedContent_IgnoresUnsupportedContent()
{
var unsupportedContent = new DataContent(new byte[] { 1, 2, 3 }, "image/png");
var chatMessage = new ChatMessage(ChatRole.User, [unsupportedContent]);
var exception = Assert.Throws<NotSupportedException>(chatMessage.ToA2AMessage);
Assert.Contains("Content type 'DataContent' is not supported", exception.Message);
var result = chatMessage.ToA2AMessage();
// Should create a message but ignore the unsupported content
Assert.NotNull(result);
Assert.Empty(result.Parts);
}
[Fact]
@@ -389,7 +392,7 @@ public class MessageConverterTests
}
[Fact]
public void ConvertPartToAIContent_FilePart_ThrowsNotSupportedException()
public void ConvertPartToAIContent_FilePart_IgnoresUnsupportedPart()
{
var filePart = new FilePart();
var message = new AgentMessage
@@ -399,12 +402,15 @@ public class MessageConverterTests
Parts = [filePart]
};
var exception = Assert.Throws<NotSupportedException>(() => new List<AgentMessage> { message }.ToChatMessages());
Assert.Contains("Part type 'FilePart' is not supported", exception.Message);
var result = new List<AgentMessage> { message }.ToChatMessages();
// Should return empty collection since FilePart is ignored
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public void ConvertPartToAIContent_DataPart_ThrowsNotSupportedException()
public void ConvertPartToAIContent_DataPart_IgnoresUnsupportedPart()
{
var dataPart = new DataPart();
var message = new AgentMessage
@@ -414,8 +420,11 @@ public class MessageConverterTests
Parts = [dataPart]
};
var exception = Assert.Throws<NotSupportedException>(() => new List<AgentMessage> { message }.ToChatMessages());
Assert.Contains("Part type 'DataPart' is not supported", exception.Message);
var result = new List<AgentMessage> { message }.ToChatMessages();
// Should return empty collection since DataPart is ignored
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
@@ -529,4 +538,57 @@ public class MessageConverterTests
var chatMessage = result.First();
Assert.Null(chatMessage.AdditionalProperties);
}
[Fact]
public void ConvertPartToAIContent_MixedPartsWithUnsupported_IgnoresUnsupportedParts()
{
var message = new AgentMessage
{
MessageId = "test",
Role = MessageRole.User,
Parts = [
new TextPart { Text = "First part" },
new DataPart(), // Unsupported - should be ignored
new TextPart { Text = "Second part" },
new FilePart() // Unsupported - should be ignored
]
};
var result = new List<AgentMessage> { message }.ToChatMessages();
Assert.NotNull(result);
Assert.Single(result);
var chatMessage = result.First();
Assert.Equal(2, chatMessage.Contents.Count);
var firstContent = Assert.IsType<TextContent>(chatMessage.Contents[0]);
Assert.Equal("First part", firstContent.Text);
var secondContent = Assert.IsType<TextContent>(chatMessage.Contents[1]);
Assert.Equal("Second part", secondContent.Text);
}
[Fact]
public void ToA2AMessage_MixedContentWithUnsupported_IgnoresUnsupportedContent()
{
var contents = new List<AIContent>
{
new TextContent("First text"),
new DataContent(new byte[] { 1, 2, 3 }, "image/png"), // Unsupported - should be ignored
new TextContent("Second text")
};
var chatMessage = new ChatMessage(ChatRole.User, contents);
var result = chatMessage.ToA2AMessage();
Assert.NotNull(result);
Assert.Equal(2, result.Parts.Count);
var firstPart = Assert.IsType<TextPart>(result.Parts[0]);
Assert.Equal("First text", firstPart.Text);
var secondPart = Assert.IsType<TextPart>(result.Parts[1]);
Assert.Equal("Second text", secondPart.Text);
}
}