// Copyright (c) Microsoft. All rights reserved. using System.Reflection; using System.Text; using System.Text.Json; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Shared.Samples; namespace Microsoft.Shared.SampleUtilities; /// /// Provides a base class for test implementations that integrate with xUnit's and /// logging infrastructure. This class also supports redirecting output to the test output /// for improved debugging and test output visibility. /// /// /// This class is designed to simplify the creation of test cases by providing access to logging and /// configuration utilities, as well as enabling Console-friendly behavior for test samples. Derived classes can use /// the property for writing test output and the property for creating /// loggers. /// public abstract class BaseSample : TextWriter { /// /// Gets the output helper used for logging test results and diagnostic messages. /// protected ITestOutputHelper Output { get; } /// /// Gets the instance used to create loggers for logging operations. /// protected ILoggerFactory LoggerFactory { get; } /// /// This property makes the samples Console friendly. Allowing them to be copied and pasted into a Console app, with minimal changes. /// public BaseSample Console => this; /// public override Encoding Encoding => Encoding.UTF8; /// /// Initializes a new instance of the class, setting up logging, configuration, and /// optionally redirecting output to the test output. /// /// This constructor initializes logging using an and sets up /// configuration from multiple sources, including a JSON file, environment variables, and user secrets. /// If is , calls to /// will be redirected to the test output provided by . /// /// The instance used to write test output. /// /// A value indicating whether output should be redirected to the test output. to redirect; otherwise, . /// protected BaseSample(ITestOutputHelper output, bool redirectSystemConsoleOutput = true) { this.Output = output; this.LoggerFactory = new XunitLogger(output); IConfigurationRoot configRoot = new ConfigurationBuilder() .AddJsonFile("appsettings.Development.json", true) .AddEnvironmentVariables() .AddUserSecrets(Assembly.GetExecutingAssembly()) .Build(); TestConfiguration.Initialize(configRoot); // Redirect System.Console output to the test output if requested if (redirectSystemConsoleOutput) { System.Console.SetOut(this); } } /// /// Writes a user message to the console. /// /// The text of the message to be sent. Cannot be null or empty. protected void WriteUserMessage(string message) => this.WriteMessageOutput(new ChatMessage(ChatRole.User, message)); /// /// Processes and writes the latest agent chat response to the console, including metadata and content details. /// /// This method formats and outputs the most recent message from the provided object. It includes the message role, author name (if available), text content, and /// additional content such as images, function calls, and function results. Usage statistics, including token /// counts, are also displayed. /// The object containing the chat messages and usage data. /// The flag to indicate whether to print usage information. Defaults to . protected void WriteResponseOutput(AgentRunResponse response, bool? printUsage = true) { if (response.Messages.Count == 0) { // If there are no messages, we can skip writing the message. return; } var message = response.Messages.Last(); this.WriteMessageOutput(message); WriteUsage(); void WriteUsage() { if (!(printUsage ?? true) || response.Usage is null) { return; } UsageDetails usageDetails = response.Usage; Console.WriteLine($" [Usage] Tokens: {usageDetails.TotalTokenCount}, Input: {usageDetails.InputTokenCount}, Output: {usageDetails.OutputTokenCount}"); } } /// /// Writes the given chat message to the console. /// /// The specified message protected void WriteMessageOutput(ChatMessage message) { string authorExpression = message.Role == ChatRole.User ? string.Empty : FormatAuthor(); string contentExpression = message.Text.Trim(); const bool IsCode = false; //message.AdditionalProperties?.ContainsKey(OpenAIAssistantAgent.CodeInterpreterMetadataKey) ?? false; const string CodeMarker = IsCode ? "\n [CODE]\n" : " "; Console.WriteLine($"\n# {message.Role}{authorExpression}:{CodeMarker}{contentExpression}"); // Provide visibility for inner content (that isn't TextContent). foreach (AIContent item in message.Contents) { if (item is DataContent image && image.HasTopLevelMediaType("image")) { Console.WriteLine($" [{item.GetType().Name}] {image.Uri?.ToString() ?? image.Uri ?? $"{image.Data.Length} bytes"}"); } else if (item is FunctionCallContent functionCall) { Console.WriteLine($" [{item.GetType().Name}] {functionCall.CallId}"); } else if (item is FunctionResultContent functionResult) { Console.WriteLine($" [{item.GetType().Name}] {functionResult.CallId} - {AsJson(functionResult.Result) ?? "*"}"); } } string FormatAuthor() => message.AuthorName is not null ? $" - {message.AuthorName ?? " * "}" : string.Empty; } /// /// Writes the streaming agent response updates to the console. /// /// This method formats and outputs the most recent message from the provided object. It includes the message role, author name (if available), text content, and /// additional content such as images, function calls, and function results. Usage statistics, including token /// counts, are also displayed. /// The object containing the chat messages and usage data. protected void WriteAgentOutput(AgentRunResponseUpdate update) { if (update.Contents.Count == 0) { // If there are no contents, we can skip writing the message. return; } string authorExpression = update.Role == ChatRole.User ? string.Empty : FormatAuthor(); string contentExpression = string.IsNullOrWhiteSpace(update.Text) ? string.Empty : update.Text; const bool IsCode = false; //message.AdditionalProperties?.ContainsKey(OpenAIAssistantAgent.CodeInterpreterMetadataKey) ?? false; const string CodeMarker = IsCode ? "\n [CODE]\n" : " "; Console.WriteLine($"\n# {update.Role}{authorExpression}:{CodeMarker}{contentExpression}"); // Provide visibility for inner content (that isn't TextContent). foreach (AIContent item in update.Contents) { if (item is DataContent image && image.HasTopLevelMediaType("image")) { Console.WriteLine($" [{item.GetType().Name}] {image.Uri?.ToString() ?? image.Uri ?? $"{image.Data.Length} bytes"}"); } else if (item is FunctionCallContent functionCall) { Console.WriteLine($" [{item.GetType().Name}] {functionCall.CallId}"); } else if (item is FunctionResultContent functionResult) { Console.WriteLine($" [{item.GetType().Name}] {functionResult.CallId} - {AsJson(functionResult.Result) ?? "*"}"); } else if (item is UsageContent usage) { Console.WriteLine(" [Usage] Tokens: {0}, Input: {1}, Output: {2}", usage?.Details?.TotalTokenCount ?? 0, usage?.Details?.InputTokenCount ?? 0, usage?.Details?.OutputTokenCount ?? 0); } } string FormatAuthor() => update.AuthorName is not null ? $" - {update.AuthorName ?? " * "}" : string.Empty; } private static readonly JsonSerializerOptions s_jsonOptionsCache = new() { WriteIndented = true }; private static string? AsJson(object? obj) { if (obj is null) { return null; } return JsonSerializer.Serialize(obj, s_jsonOptionsCache); } /// public override void WriteLine(object? value = null) => this.Output.WriteLine(value ?? string.Empty); /// public override void WriteLine(string? format, params object?[] arg) => this.Output.WriteLine(format ?? string.Empty, arg); /// public override void WriteLine(string? value) => this.Output.WriteLine(value ?? string.Empty); /// public override void Write(object? value = null) => this.Output.WriteLine(value ?? string.Empty); /// public override void Write(char[]? buffer) => this.Output.WriteLine(new string(buffer)); }