// 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));
}