diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIAssistantsExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIAssistantsExtensions.cs new file mode 100644 index 00000000000..900883c6d43 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIAssistantsExtensions.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace OpenAI.Assistants; + +/// Provides extension methods for working with content associated with OpenAI.Assistants. +public static class MicrosoftExtensionsAIAssistantsExtensions +{ + /// Creates an OpenAI from an . + /// The function to convert. + /// An OpenAI representing . + /// is . + public static FunctionToolDefinition AsOpenAIAssistantsFunctionToolDefinition(this AIFunction function) => + OpenAIAssistantsChatClient.ToOpenAIAssistantsFunctionToolDefinition(Throw.IfNull(function)); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIChatExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIChatExtensions.cs new file mode 100644 index 00000000000..c7eb3b2e03e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIChatExtensions.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace OpenAI.Chat; + +/// Provides extension methods for working with content associated with OpenAI.Chat. +public static class MicrosoftExtensionsAIChatExtensions +{ + /// Creates an OpenAI from an . + /// The function to convert. + /// An OpenAI representing . + /// is . + public static ChatTool AsOpenAIChatTool(this AIFunction function) => + OpenAIChatClient.ToOpenAIChatTool(Throw.IfNull(function)); + + /// Creates a sequence of OpenAI instances from the specified input messages. + /// The input messages to convert. + /// A sequence of OpenAI chat messages. + public static IEnumerable AsOpenAIChatMessages(this IEnumerable messages) => + OpenAIChatClient.ToOpenAIChatMessages(Throw.IfNull(messages), chatOptions: null); + + /// Creates a Microsoft.Extensions.AI from a . + /// The to convert to a . + /// The options employed in the creation of the response. + /// A converted . + public static ChatResponse AsChatResponse(this ChatCompletion chatCompletion, ChatCompletionOptions? options = null) => + OpenAIChatClient.FromOpenAIChatCompletion(Throw.IfNull(chatCompletion), options); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIRealtimeExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIRealtimeExtensions.cs new file mode 100644 index 00000000000..ad180e96b1e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIRealtimeExtensions.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace OpenAI.Realtime; + +/// Provides extension methods for working with content associated with OpenAI.Realtime. +public static class MicrosoftExtensionsAIRealtimeExtensions +{ + /// Creates an OpenAI from an . + /// The function to convert. + /// An OpenAI representing . + /// is . + public static ConversationFunctionTool AsOpenAIConversationFunctionTool(this AIFunction function) => + OpenAIRealtimeConversationClient.ToOpenAIConversationFunctionTool(Throw.IfNull(function)); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs new file mode 100644 index 00000000000..e54b3092c5a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace OpenAI.Responses; + +/// Provides extension methods for working with content associated with OpenAI.Responses. +public static class MicrosoftExtensionsAIResponsesExtensions +{ + /// Creates an OpenAI from an . + /// The function to convert. + /// An OpenAI representing . + /// is . + public static ResponseTool AsOpenAIResponseTool(this AIFunction function) => + OpenAIResponsesChatClient.ToResponseTool(Throw.IfNull(function)); + + /// Creates a sequence of OpenAI instances from the specified input messages. + /// The input messages to convert. + /// A sequence of OpenAI response items. + public static IEnumerable AsOpenAIResponseItems(this IEnumerable messages) => + OpenAIResponsesChatClient.ToOpenAIResponseItems(Throw.IfNull(messages)); + + /// Creates a Microsoft.Extensions.AI from an . + /// The to convert to a . + /// The options employed in the creation of the response. + /// A converted . + public static ChatResponse AsChatResponse(this OpenAIResponse response, ResponseCreationOptions? options = null) => + OpenAIResponsesChatClient.FromOpenAIResponse(Throw.IfNull(response), options); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs similarity index 96% rename from src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs rename to src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs index c1d59e30a68..46bc39ee278 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs @@ -26,8 +26,8 @@ namespace Microsoft.Extensions.AI; -/// Represents an for an Azure.AI.Agents.Persistent . -internal sealed class OpenAIAssistantChatClient : IChatClient +/// Represents an for an OpenAI . +internal sealed class OpenAIAssistantsChatClient : IChatClient { /// The underlying . private readonly AssistantClient _client; @@ -44,8 +44,8 @@ internal sealed class OpenAIAssistantChatClient : IChatClient /// List of tools associated with the assistant. private IReadOnlyList? _assistantTools; - /// Initializes a new instance of the class for the specified . - public OpenAIAssistantChatClient(AssistantClient assistantClient, string assistantId, string? defaultThreadId) + /// Initializes a new instance of the class for the specified . + public OpenAIAssistantsChatClient(AssistantClient assistantClient, string assistantId, string? defaultThreadId) { _client = Throw.IfNull(assistantClient); _assistantId = Throw.IfNullOrWhitespace(assistantId); @@ -62,6 +62,13 @@ public OpenAIAssistantChatClient(AssistantClient assistantClient, string assista _metadata = new("openai", providerUrl); } + /// Initializes a new instance of the class for the specified . + public OpenAIAssistantsChatClient(AssistantClient assistantClient, Assistant assistant, string? defaultThreadId) + : this(assistantClient, Throw.IfNull(assistant).Id, defaultThreadId) + { + _assistantTools = assistant.Tools; + } + /// public object? GetService(Type serviceType, object? serviceKey = null) => serviceType is null ? throw new ArgumentNullException(nameof(serviceType)) : diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 52dfc3dc8af..9fc2f3cbeef 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -17,6 +17,7 @@ #pragma warning disable EA0011 // Consider removing unnecessary conditional access operator (?) #pragma warning disable S1067 // Expressions should not be too complex #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields +#pragma warning disable SA1202 // Elements should be ordered by access #pragma warning disable SA1204 // Static elements should appear before instance elements namespace Microsoft.Extensions.AI; @@ -76,7 +77,7 @@ public async Task GetResponseAsync( // Make the call to OpenAI. var response = await _chatClient.CompleteChatAsync(openAIChatMessages, openAIOptions, cancellationToken).ConfigureAwait(false); - return FromOpenAIChatCompletion(response.Value, options, openAIOptions); + return FromOpenAIChatCompletion(response.Value, openAIOptions); } /// @@ -430,7 +431,7 @@ private static string GetOutputAudioMimeType(ChatCompletionOptions? options) => "mp3" or _ => "audio/mpeg", }; - private static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAICompletion, ChatOptions? options, ChatCompletionOptions chatCompletionOptions) + internal static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAICompletion, ChatCompletionOptions? chatCompletionOptions) { _ = Throw.IfNull(openAICompletion); @@ -461,17 +462,14 @@ private static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAIComple } // Also manufacture function calling content items from any tool calls in the response. - if (options?.Tools is { Count: > 0 }) + foreach (ChatToolCall toolCall in openAICompletion.ToolCalls) { - foreach (ChatToolCall toolCall in openAICompletion.ToolCalls) + if (!string.IsNullOrWhiteSpace(toolCall.FunctionName)) { - if (!string.IsNullOrWhiteSpace(toolCall.FunctionName)) - { - var callContent = ParseCallContentFromBinaryData(toolCall.FunctionArguments, toolCall.Id, toolCall.FunctionName); - callContent.RawRepresentation = toolCall; + var callContent = ParseCallContentFromBinaryData(toolCall.FunctionArguments, toolCall.Id, toolCall.FunctionName); + callContent.RawRepresentation = toolCall; - returnMessage.Contents.Add(callContent); - } + returnMessage.Contents.Add(callContent); } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index 9f42fa88773..889998cc933 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -8,20 +8,15 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; -using Microsoft.Shared.Diagnostics; using OpenAI; using OpenAI.Assistants; using OpenAI.Audio; using OpenAI.Chat; using OpenAI.Embeddings; -using OpenAI.Realtime; using OpenAI.Responses; #pragma warning disable S103 // Lines should not be too long -#pragma warning disable S1067 // Expressions should not be too complex #pragma warning disable SA1515 // Single-line comment should be preceded by blank line -#pragma warning disable CA1305 // Specify IFormatProvider -#pragma warning disable S1135 // Track uses of "TODO" tags namespace Microsoft.Extensions.AI; @@ -120,7 +115,7 @@ public static IChatClient AsIChatClient(this ChatClient chatClient) => /// An that can be used to converse via the . /// is . public static IChatClient AsIChatClient(this OpenAIResponseClient responseClient) => - new OpenAIResponseChatClient(responseClient); + new OpenAIResponsesChatClient(responseClient); /// Gets an for use with this . /// The instance to be accessed as an . @@ -135,7 +130,21 @@ public static IChatClient AsIChatClient(this OpenAIResponseClient responseClient /// is . /// is empty or composed entirely of whitespace. public static IChatClient AsIChatClient(this AssistantClient assistantClient, string assistantId, string? threadId = null) => - new OpenAIAssistantChatClient(assistantClient, assistantId, threadId); + new OpenAIAssistantsChatClient(assistantClient, assistantId, threadId); + + /// Gets an for use with this . + /// The instance to be accessed as an . + /// The with which to interact. + /// + /// An optional existing thread identifier for the chat session. This serves as a default, and may be overridden per call to + /// or via the + /// property. If no thread ID is provided via either mechanism, a new thread will be created for the request. + /// + /// An instance configured to interact with the specified agent and thread. + /// is . + /// is . + public static IChatClient AsIChatClient(this AssistantClient assistantClient, Assistant assistant, string? threadId = null) => + new OpenAIAssistantsChatClient(assistantClient, assistant, threadId); /// Gets an for use with this . /// The client. @@ -153,48 +162,6 @@ public static ISpeechToTextClient AsISpeechToTextClient(this AudioClient audioCl public static IEmbeddingGenerator> AsIEmbeddingGenerator(this EmbeddingClient embeddingClient, int? defaultModelDimensions = null) => new OpenAIEmbeddingGenerator(embeddingClient, defaultModelDimensions); - /// Creates an OpenAI from an . - /// The function to convert. - /// An OpenAI representing . - /// is . - public static ChatTool AsOpenAIChatTool(this AIFunction function) => - OpenAIChatClient.ToOpenAIChatTool(Throw.IfNull(function)); - - /// Creates an OpenAI from an . - /// The function to convert. - /// An OpenAI representing . - /// is . - public static FunctionToolDefinition AsOpenAIAssistantsFunctionToolDefinition(this AIFunction function) => - OpenAIAssistantChatClient.ToOpenAIAssistantsFunctionToolDefinition(Throw.IfNull(function)); - - /// Creates an OpenAI from an . - /// The function to convert. - /// An OpenAI representing . - /// is . - public static ResponseTool AsOpenAIResponseTool(this AIFunction function) => - OpenAIResponseChatClient.ToResponseTool(Throw.IfNull(function)); - - /// Creates an OpenAI from an . - /// The function to convert. - /// An OpenAI representing . - /// is . - public static ConversationFunctionTool AsOpenAIConversationFunctionTool(this AIFunction function) => - OpenAIRealtimeConversationClient.ToOpenAIConversationFunctionTool(Throw.IfNull(function)); - - /// Creates a sequence of OpenAI instances from the specified input messages. - /// The input messages to convert. - /// A sequence of OpenAI chat messages. - public static IEnumerable AsOpenAIChatMessages(this IEnumerable messages) => - OpenAIChatClient.ToOpenAIChatMessages(Throw.IfNull(messages), chatOptions: null); - - /// Creates a sequence of OpenAI instances from the specified input messages. - /// The input messages to convert. - /// A sequence of OpenAI response items. - public static IEnumerable AsOpenAIResponseItems(this IEnumerable messages) => - OpenAIResponseChatClient.ToOpenAIResponseItems(Throw.IfNull(messages)); - - // TODO: Once we're ready to rely on C# 14 features, add an extension property ChatOptions.Strict. - /// Gets whether the properties specify that strict schema handling is desired. internal static bool? HasStrict(IReadOnlyDictionary? additionalProperties) => additionalProperties?.TryGetValue(StrictKey, out object? strictObj) is true && diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs index 004f6274265..a6c4af831ba 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs @@ -8,7 +8,6 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; -using OpenAI; using OpenAI.Embeddings; #pragma warning disable S1067 // Expressions should not be too complex diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs similarity index 98% rename from src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs rename to src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index b5ba36b5e1a..60d0e10a159 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -23,7 +23,7 @@ namespace Microsoft.Extensions.AI; /// Represents an for an . -internal sealed class OpenAIResponseChatClient : IChatClient +internal sealed class OpenAIResponsesChatClient : IChatClient { /// Metadata about the client. private readonly ChatClientMetadata _metadata; @@ -31,10 +31,10 @@ internal sealed class OpenAIResponseChatClient : IChatClient /// The underlying . private readonly OpenAIResponseClient _responseClient; - /// Initializes a new instance of the class for the specified . + /// Initializes a new instance of the class for the specified . /// The underlying client. /// is . - public OpenAIResponseChatClient(OpenAIResponseClient responseClient) + public OpenAIResponsesChatClient(OpenAIResponseClient responseClient) { _ = Throw.IfNull(responseClient); @@ -78,10 +78,16 @@ public async Task GetResponseAsync( // Make the call to the OpenAIResponseClient. var openAIResponse = (await _responseClient.CreateResponseAsync(openAIResponseItems, openAIOptions, cancellationToken).ConfigureAwait(false)).Value; + // Convert the response to a ChatResponse. + return FromOpenAIResponse(openAIResponse, openAIOptions); + } + + internal static ChatResponse FromOpenAIResponse(OpenAIResponse openAIResponse, ResponseCreationOptions? openAIOptions) + { // Convert and return the results. ChatResponse response = new() { - ConversationId = openAIOptions.StoredOutputEnabled is false ? null : openAIResponse.Id, + ConversationId = openAIOptions?.StoredOutputEnabled is false ? null : openAIResponse.Id, CreatedAt = openAIResponse.CreatedAt, FinishReason = ToFinishReason(openAIResponse.IncompleteStatusDetails?.Reason), Messages = [new(ChatRole.Assistant, [])], diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs index 3b084b5ec8a..7779e4cf18d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs @@ -19,7 +19,8 @@ public class OpenAIAssistantChatClientTests public void AsIChatClient_InvalidArgs_Throws() { Assert.Throws("assistantClient", () => ((AssistantClient)null!).AsIChatClient("assistantId")); - Assert.Throws("assistantId", () => new AssistantClient("ignored").AsIChatClient(null!)); + Assert.Throws("assistantId", () => new AssistantClient("ignored").AsIChatClient((string)null!)); + Assert.Throws("assistant", () => new AssistantClient("ignored").AsIChatClient((Assistant)null!)); } [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index d06d8f520be..640dab54f8e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -398,7 +398,7 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat() }; openAIOptions.StopSequences.Add("hello"); - openAIOptions.Tools.Add(OpenAIClientExtensions.AsOpenAIChatTool(tool)); + openAIOptions.Tools.Add(tool.AsOpenAIChatTool()); return openAIOptions; }, ModelId = null, @@ -475,7 +475,7 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat() }; openAIOptions.StopSequences.Add("hello"); - openAIOptions.Tools.Add(OpenAIClientExtensions.AsOpenAIChatTool(tool)); + openAIOptions.Tools.Add(tool.AsOpenAIChatTool()); return openAIOptions; }, ModelId = null, // has no effect, you cannot change the model of an OpenAI's ChatClient. diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs index 951554eda75..73f3ae65548 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs @@ -183,4 +183,37 @@ public void AsOpenAIResponseItems_ProducesExpectedOutput() Assert.Equal(OpenAI.Responses.MessageRole.Assistant, m5.Role); Assert.Equal("The answer is 42.", Assert.Single(m5.Content).Text); } + + [Fact] + public void AsChatResponse_ConvertsOpenAIChatCompletion() + { + Assert.Throws("chatCompletion", () => ((ChatCompletion)null!).AsChatResponse()); + + ChatCompletion cc = OpenAIChatModelFactory.ChatCompletion( + "id", OpenAI.Chat.ChatFinishReason.Length, null, null, + [ChatToolCall.CreateFunctionToolCall("id", "functionName", BinaryData.FromString("test"))], + ChatMessageRole.User, null, null, null, new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), + "model123", null, OpenAIChatModelFactory.ChatTokenUsage(2, 1, 3)); + cc.Content.Add(ChatMessageContentPart.CreateTextPart("Hello, world!")); + cc.Content.Add(ChatMessageContentPart.CreateImagePart(new Uri("http://example.com/image.png"))); + + ChatResponse response = cc.AsChatResponse(); + + Assert.Equal("id", response.ResponseId); + Assert.Equal(ChatFinishReason.Length, response.FinishReason); + Assert.Equal("model123", response.ModelId); + Assert.Equal(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), response.CreatedAt); + Assert.NotNull(response.Usage); + Assert.Equal(1, response.Usage.InputTokenCount); + Assert.Equal(2, response.Usage.OutputTokenCount); + Assert.Equal(3, response.Usage.TotalTokenCount); + + ChatMessage message = Assert.Single(response.Messages); + Assert.Equal(ChatRole.User, message.Role); + + Assert.Equal(3, message.Contents.Count); + Assert.Equal("Hello, world!", Assert.IsType(message.Contents[0]).Text); + Assert.Equal("http://example.com/image.png", Assert.IsType(message.Contents[1]).Uri.ToString()); + Assert.Equal("functionName", Assert.IsType(message.Contents[2]).Name); + } }