Compare commits

...
18 changed files with 1082 additions and 0 deletions
+2
View File
@@ -464,6 +464,8 @@
<Folder Name="/Tests/" />
<Folder Name="/Tests/IntegrationTests/">
<Project Path="tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj" />
<Project Path="tests/AgentConversation.IntegrationTests/AgentConversation.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Abstractions.IntegrationTests/Microsoft.Agents.AI.Abstractions.IntegrationTests.csproj" />
<Project Path="tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletion.IntegrationTests.csproj" />
<Project Path="tests/AzureAI.IntegrationTests/AzureAI.IntegrationTests.csproj" />
<Project Path="tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj" />
@@ -0,0 +1 @@
bin/
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsTestProject>false</IsTestProject>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectRequiredMemberOnLegacy>true</InjectRequiredMemberOnLegacy>
<InjectCompilerFeatureRequiredOnLegacy>true</InjectCompilerFeatureRequiredOnLegacy>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace AgentConversation.IntegrationTests;
/// <summary>
/// Defines an agent participating in a <see cref="IConversationTestCase"/>.
/// </summary>
public sealed class ConversationAgentDefinition
{
/// <summary>
/// Gets or sets the unique name identifying this agent within the test case.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Gets or sets the system instructions for the agent.
/// </summary>
public string Instructions { get; init; } = "You are a helpful assistant.";
/// <summary>
/// Gets or sets the optional list of tools available to the agent.
/// </summary>
public IList<AITool>? Tools { get; init; }
}
@@ -0,0 +1,70 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace AgentConversation.IntegrationTests;
/// <summary>
/// Provides helpers for serializing and deserializing conversation contexts (lists of <see cref="ChatMessage"/>)
/// to and from JSON, enabling the initial context of a test case to be captured once and reused across runs.
/// </summary>
public static class ConversationContextSerializer
{
private static readonly JsonSerializerOptions s_serializerOptions = AgentAbstractionsJsonUtilities.DefaultOptions;
/// <summary>
/// Serializes a list of <see cref="ChatMessage"/> instances to a JSON string.
/// </summary>
/// <param name="messages">The messages to serialize.</param>
/// <returns>A JSON string representation of the messages.</returns>
public static string Serialize(IList<ChatMessage> messages) =>
JsonSerializer.Serialize(messages, s_serializerOptions);
/// <summary>
/// Deserializes a JSON string into a list of <see cref="ChatMessage"/> instances.
/// </summary>
/// <param name="json">The JSON string to deserialize.</param>
/// <returns>The deserialized list of messages.</returns>
/// <exception cref="InvalidOperationException">
/// Thrown when the JSON cannot be deserialized into a list of <see cref="ChatMessage"/> instances.
/// </exception>
public static IList<ChatMessage> Deserialize(string json)
{
var messages = JsonSerializer.Deserialize<List<ChatMessage>>(json, s_serializerOptions);
return messages ?? throw new InvalidOperationException("Failed to deserialize chat messages from the provided JSON.");
}
/// <summary>
/// Saves a list of <see cref="ChatMessage"/> instances to a JSON file.
/// </summary>
/// <param name="filePath">The path of the file to write.</param>
/// <param name="messages">The messages to save.</param>
public static void SaveToFile(string filePath, IList<ChatMessage> messages)
{
var json = Serialize(messages);
File.WriteAllText(filePath, json);
}
/// <summary>
/// Loads a list of <see cref="ChatMessage"/> instances from a JSON file.
/// </summary>
/// <param name="filePath">The path of the file to read.</param>
/// <returns>The deserialized list of messages.</returns>
/// <exception cref="FileNotFoundException">Thrown when <paramref name="filePath"/> does not exist.</exception>
public static IList<ChatMessage> LoadFromFile(string filePath)
{
if (!File.Exists(filePath))
{
throw new FileNotFoundException($"Conversation context file not found: '{filePath}'. " +
"Run the context creation step first to generate this file.", filePath);
}
var json = File.ReadAllText(filePath);
return Deserialize(json);
}
}
@@ -0,0 +1,214 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace AgentConversation.IntegrationTests;
/// <summary>
/// Orchestrates the execution of a <see cref="IConversationTestCase"/> against a given
/// <see cref="IConversationTestSystem"/>: restores the conversation context, optionally runs compaction,
/// executes each step, captures before/after metrics, and runs per-step validations.
/// </summary>
public sealed class ConversationHarness
{
private readonly IConversationTestSystem _system;
/// <summary>
/// Initializes a new instance of <see cref="ConversationHarness"/>.
/// </summary>
/// <param name="system">The system under test that provides agent creation and compaction.</param>
public ConversationHarness(IConversationTestSystem system)
{
if (system is null)
{
throw new ArgumentNullException(nameof(system));
}
this._system = system;
}
/// <summary>
/// Runs the supplied <paramref name="testCase"/> from its serialized initial context, executing
/// every <see cref="ConversationStep"/> in order and returning the combined metrics report.
/// </summary>
/// <param name="testCase">The test case to execute.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>
/// A <see cref="ConversationMetricsReport"/> describing the before-and-after state of the
/// conversation context across all steps.
/// </returns>
/// <exception cref="ArgumentNullException"><paramref name="testCase"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">
/// Thrown when a step references an agent name that is not present in <see cref="IConversationTestCase.AgentDefinitions"/>.
/// </exception>
public async Task<ConversationMetricsReport> RunAsync(
IConversationTestCase testCase,
CancellationToken cancellationToken = default)
{
if (testCase is null)
{
throw new ArgumentNullException(nameof(testCase));
}
// 1. Restore the initial context.
var initialMessages = testCase.GetInitialMessages();
// 2. Capture "before" metrics.
var beforeMetrics = MeasureMetrics(initialMessages);
// 3. Create the agents defined for this test case.
var agents = new Dictionary<string, AIAgent>(StringComparer.Ordinal);
foreach (var entry in testCase.AgentDefinitions)
{
agents[entry.Key] = await this._system.CreateAgentAsync(entry.Value, cancellationToken).ConfigureAwait(false);
}
// 4. Create sessions and restore the initial messages for each agent.
var sessions = new Dictionary<string, AgentSession>(StringComparer.Ordinal);
foreach (var entry in agents)
{
var session = await entry.Value.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
RestoreMessages(entry.Value, session, initialMessages);
sessions[entry.Key] = session;
}
// 5. Optionally compact the messages.
var compacted = await this._system.CompactAsync(initialMessages, cancellationToken).ConfigureAwait(false);
if (compacted is not null)
{
// Apply the compacted history to all agent sessions.
foreach (var entry in agents)
{
RestoreMessages(entry.Value, sessions[entry.Key], compacted);
}
}
// 6. Execute each step.
foreach (var step in testCase.Steps)
{
if (!agents.TryGetValue(step.AgentName, out var agent))
{
throw new InvalidOperationException(
$"Step references agent '{step.AgentName}' which is not defined in the test case. " +
$"Defined agents: {string.Join(", ", agents.Keys)}");
}
var session = sessions[step.AgentName];
AgentResponse response;
if (step.Input is not null)
{
response = await agent.RunAsync(step.Input, session, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
response = await agent.RunAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false);
}
// 7. Capture "after" metrics for this step and run the step's validation.
var currentMessages = GetCurrentMessages(agent, sessions[step.AgentName], initialMessages, compacted);
var afterMetrics = MeasureMetrics(currentMessages);
var metricsReport = new ConversationMetricsReport
{
Before = beforeMetrics,
After = afterMetrics,
};
step.Validate?.Invoke(response, metricsReport);
}
// 8. Capture the final "after" metrics from the first agent's session.
var firstAgent = agents.Values.First();
var firstSession = sessions[agents.Keys.First()];
var finalMessages = GetCurrentMessages(firstAgent, firstSession, initialMessages, compacted);
var finalAfterMetrics = MeasureMetrics(finalMessages);
return new ConversationMetricsReport
{
Before = beforeMetrics,
After = finalAfterMetrics,
};
}
/// <summary>
/// Drives a conversation with the agents defined in <paramref name="testCase"/> to produce the initial
/// context, then serializes that context to <paramref name="outputFilePath"/>.
/// </summary>
/// <remarks>
/// This method should be called once (outside of normal test execution) to generate the fixture
/// data that tests will subsequently restore via <see cref="IConversationTestCase.GetInitialMessages"/>.
/// </remarks>
/// <param name="testCase">The test case whose initial context should be created.</param>
/// <param name="outputFilePath">The file path to write the serialized context to.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
public async Task SerializeInitialContextAsync(
IConversationTestCase testCase,
string outputFilePath,
CancellationToken cancellationToken = default)
{
if (testCase is null)
{
throw new ArgumentNullException(nameof(testCase));
}
if (string.IsNullOrEmpty(outputFilePath))
{
throw new ArgumentException("Output file path must not be null or empty.", nameof(outputFilePath));
}
// Create agents for context generation.
var agents = new Dictionary<string, AIAgent>(StringComparer.Ordinal);
foreach (var entry in testCase.AgentDefinitions)
{
agents[entry.Key] = await this._system.CreateAgentAsync(entry.Value, cancellationToken).ConfigureAwait(false);
}
var messages = await testCase.CreateInitialContextAsync(agents, cancellationToken).ConfigureAwait(false);
ConversationContextSerializer.SaveToFile(outputFilePath, messages);
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private static ConversationMetrics MeasureMetrics(IList<ChatMessage> messages)
{
var serialized = ConversationContextSerializer.Serialize(messages);
return new ConversationMetrics
{
MessageCount = messages.Count,
SerializedSizeBytes = System.Text.Encoding.UTF8.GetByteCount(serialized),
};
}
private static void RestoreMessages(AIAgent agent, AgentSession session, IList<ChatMessage> messages)
{
// InMemoryChatHistoryProvider is the standard history provider for ChatClientAgent.
// When found, load the messages directly into the provider's state for this session.
if (agent.GetService<ChatHistoryProvider>() is InMemoryChatHistoryProvider memProvider)
{
memProvider.SetMessages(session, messages.ToList());
}
}
private static IList<ChatMessage> GetCurrentMessages(
AIAgent agent,
AgentSession session,
IList<ChatMessage> fallbackInitial,
IList<ChatMessage>? compacted)
{
if (agent.GetService<ChatHistoryProvider>() is InMemoryChatHistoryProvider memProvider)
{
return memProvider.GetMessages(session);
}
// Fall back to the compacted (or original) initial messages when the provider is unavailable.
return compacted ?? fallbackInitial;
}
}
@@ -0,0 +1,138 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;
using Xunit.Abstractions;
namespace AgentConversation.IntegrationTests;
/// <summary>
/// Abstract xunit base class for agent conversation integration tests.
/// </summary>
/// <remarks>
/// <para>
/// Subclasses must implement <see cref="CreateTestSystem"/> and <see cref="GetTestCases"/> to provide
/// the AI backend and the set of test cases to run. Each subclass will automatically inherit the
/// <see cref="RunAllTestCasesAsync"/> test method, which runs every case returned by
/// <see cref="GetTestCases"/> through the <see cref="ConversationHarness"/>.
/// </para>
/// <para>
/// To generate (and serialize) the initial context for a test case, the same subclass inherits
/// <see cref="SerializeAllInitialContextsAsync"/>, which should be run once outside of normal CI.
/// </para>
/// </remarks>
/// <typeparam name="TSystem">
/// The concrete <see cref="IConversationTestSystem"/> implementation that provides agent creation
/// and compaction for the system under test.
/// </typeparam>
public abstract class ConversationHarnessTests<TSystem>
where TSystem : IConversationTestSystem
{
private readonly ITestOutputHelper? _output;
/// <summary>
/// Initializes a new instance of <see cref="ConversationHarnessTests{TSystem}"/>.
/// </summary>
protected ConversationHarnessTests()
{
}
/// <summary>
/// Initializes a new instance of <see cref="ConversationHarnessTests{TSystem}"/> with xunit test output.
/// </summary>
/// <param name="output">The xunit test output helper used to log metrics and step results.</param>
protected ConversationHarnessTests(ITestOutputHelper output)
{
this._output = output;
}
/// <summary>
/// Creates the <see cref="IConversationTestSystem"/> to use for agent creation and compaction.
/// </summary>
protected abstract TSystem CreateTestSystem();
/// <summary>
/// Returns the set of <see cref="IConversationTestCase"/> instances to exercise.
/// </summary>
protected abstract IEnumerable<IConversationTestCase> GetTestCases();
/// <summary>
/// Runs all test cases returned by <see cref="GetTestCases"/> and logs the metrics report for each.
/// </summary>
[Fact]
public virtual async Task RunAllTestCasesAsync()
{
var system = this.CreateTestSystem();
var harness = new ConversationHarness(system);
foreach (var testCase in this.GetTestCases())
{
this.Log($"[{testCase.Name}] Running...");
var stopwatch = Stopwatch.StartNew();
var report = await harness.RunAsync(testCase);
stopwatch.Stop();
this.Log($"[{testCase.Name}] Completed in {stopwatch.ElapsedMilliseconds}ms. Metrics: {report}");
}
}
/// <summary>
/// Generates and serializes the initial context for each test case returned by <see cref="GetTestCases"/>.
/// </summary>
/// <remarks>
/// This test is skipped during normal test runs because generating contexts requires live AI calls and
/// can be expensive. Run it explicitly (e.g., with <c>dotnet test --filter "FullyQualifiedName~Serialize"</c>)
/// to regenerate the fixture files. After running, commit the generated files alongside the test code.
/// </remarks>
[Fact(Skip = "Run explicitly to regenerate initial context fixture files.")]
public virtual async Task SerializeAllInitialContextsAsync()
{
var system = this.CreateTestSystem();
var harness = new ConversationHarness(system);
foreach (var testCase in this.GetTestCases())
{
var outputPath = GetDefaultContextFilePath(testCase);
this.Log($"[{testCase.Name}] Serializing initial context to '{outputPath}'...");
await harness.SerializeInitialContextAsync(testCase, outputPath);
this.Log($"[{testCase.Name}] Context serialized successfully.");
}
}
// -------------------------------------------------------------------------
// Protected helpers for subclasses
// -------------------------------------------------------------------------
/// <summary>
/// Computes the default file path used by <see cref="SerializeAllInitialContextsAsync"/> when
/// writing the initial context for <paramref name="testCase"/>.
/// </summary>
/// <remarks>
/// Override this method to change the output location. The default path is
/// <c>{TestCase.Name}.context.json</c> relative to the current working directory.
/// </remarks>
/// <param name="testCase">The test case whose default output path is required.</param>
/// <returns>The absolute or relative file path to write the serialized context to.</returns>
protected virtual string GetDefaultContextFilePath(IConversationTestCase testCase) =>
$"{testCase.Name}.context.json";
/// <summary>
/// Writes a message to the xunit test output, if available, otherwise to the console.
/// </summary>
protected void Log(string message)
{
if (this._output is not null)
{
this._output.WriteLine(message);
}
else
{
Console.WriteLine(message);
}
}
}
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft. All rights reserved.
namespace AgentConversation.IntegrationTests;
/// <summary>
/// Captures the size characteristics of a conversation context at a specific point in time.
/// </summary>
public sealed class ConversationMetrics
{
/// <summary>
/// Gets the number of messages in the conversation context.
/// </summary>
public required int MessageCount { get; init; }
/// <summary>
/// Gets the approximate serialized size of the conversation context in bytes.
/// This serves as a proxy for context window consumption.
/// </summary>
public required long SerializedSizeBytes { get; init; }
/// <inheritdoc />
public override string ToString() =>
$"Messages={MessageCount}, Size={SerializedSizeBytes}B";
}
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
namespace AgentConversation.IntegrationTests;
/// <summary>
/// Captures the before-and-after <see cref="ConversationMetrics"/> for a single test case run,
/// enabling comparison and reporting of context size changes.
/// </summary>
public sealed class ConversationMetricsReport
{
/// <summary>
/// Gets the metrics captured before the agent steps were executed.
/// </summary>
public required ConversationMetrics Before { get; init; }
/// <summary>
/// Gets the metrics captured after the agent steps were executed.
/// </summary>
public required ConversationMetrics After { get; init; }
/// <summary>
/// Gets the change in message count between <see cref="Before"/> and <see cref="After"/>.
/// A positive value means messages were added; a negative value means compaction removed messages.
/// </summary>
public int MessageCountDelta => After.MessageCount - Before.MessageCount;
/// <summary>
/// Gets the change in serialized size in bytes between <see cref="Before"/> and <see cref="After"/>.
/// </summary>
public long SizeDeltaBytes => After.SerializedSizeBytes - Before.SerializedSizeBytes;
/// <inheritdoc />
public override string ToString() =>
$"Before=[{Before}] After=[{After}] Delta=[Messages={MessageCountDelta:+#;-#;0}, Size={SizeDeltaBytes:+#;-#;0}B]";
}
@@ -0,0 +1,33 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace AgentConversation.IntegrationTests;
/// <summary>
/// Represents a single step within a <see cref="IConversationTestCase"/>, combining the agent to invoke,
/// an optional input message, and an optional validation delegate.
/// </summary>
public sealed class ConversationStep
{
/// <summary>
/// Gets or sets the name of the agent to invoke for this step.
/// Must match a key in <see cref="IConversationTestCase.AgentDefinitions"/>.
/// </summary>
public required string AgentName { get; init; }
/// <summary>
/// Gets or sets the optional input message to send to the agent.
/// When <see langword="null"/>, the agent is invoked with no new user input (useful for
/// eliciting a response from the existing conversation context).
/// </summary>
public ChatMessage? Input { get; init; }
/// <summary>
/// Gets or sets an optional delegate that validates the agent response and metrics for this step.
/// When <see langword="null"/>, no validation is performed.
/// </summary>
public Action<AgentResponse, ConversationMetricsReport>? Validate { get; init; }
}
@@ -0,0 +1,68 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace AgentConversation.IntegrationTests;
/// <summary>
/// Defines a single agent conversation test case.
/// </summary>
/// <remarks>
/// Each test case describes the initial conversation context (as a list of <see cref="ChatMessage"/> instances),
/// the agents that participate in the conversation, the steps to execute, and the expected outcomes.
/// The initial context can either be loaded from a previously serialized file or generated on-demand
/// via <see cref="CreateInitialContextAsync"/>.
/// </remarks>
public interface IConversationTestCase
{
/// <summary>
/// Gets the human-readable name that uniquely identifies this test case.
/// </summary>
string Name { get; }
/// <summary>
/// Gets the agents involved in this test case, keyed by their names.
/// Each entry is a <see cref="ConversationAgentDefinition"/> that describes how to create the agent.
/// </summary>
IReadOnlyDictionary<string, ConversationAgentDefinition> AgentDefinitions { get; }
/// <summary>
/// Returns the initial list of <see cref="ChatMessage"/> instances to restore into the conversation
/// context before any steps are executed.
/// </summary>
/// <returns>
/// The initial chat messages. These are typically loaded from a previously serialized JSON file
/// produced by <see cref="CreateInitialContextAsync"/>.
/// </returns>
IList<ChatMessage> GetInitialMessages();
/// <summary>
/// Gets the ordered list of steps to execute against the restored conversation context.
/// </summary>
IReadOnlyList<ConversationStep> Steps { get; }
/// <summary>
/// Creates the initial conversation context by actually driving a conversation with the provided agents,
/// then returns the resulting list of messages.
/// </summary>
/// <remarks>
/// This method is intended to be called once (e.g., during a setup phase) to produce the serialized
/// context that subsequent test runs will deserialize. Implementations should build up a long or
/// complex conversation that is representative of the long-running operation scenario being validated.
/// </remarks>
/// <param name="agents">
/// The agents to use when building the initial context, keyed by their names as defined in
/// <see cref="AgentDefinitions"/>.
/// </param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>
/// The ordered list of <see cref="ChatMessage"/> instances that form the initial context.
/// </returns>
Task<IList<ChatMessage>> CreateInitialContextAsync(
IReadOnlyDictionary<string, AIAgent> agents,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,52 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace AgentConversation.IntegrationTests;
/// <summary>
/// Abstracts the system-specific concerns of an agent conversation test run: how agents are created
/// and how context compaction is performed.
/// </summary>
/// <remarks>
/// Implement this interface to adapt the <see cref="ConversationHarness"/> to a particular AI backend
/// (e.g., OpenAI Chat Completion, Azure AI, OpenAI Responses API). Each implementation controls:
/// <list type="bullet">
/// <item><description>
/// How <see cref="ConversationAgentDefinition"/> instances are turned into live <see cref="AIAgent"/> objects.
/// </description></item>
/// <item><description>
/// How context compaction is applied to a list of messages. Compaction is optional; returning
/// <see langword="null"/> means no compaction is performed.
/// </description></item>
/// </list>
/// </remarks>
public interface IConversationTestSystem
{
/// <summary>
/// Creates a live <see cref="AIAgent"/> from the supplied <paramref name="definition"/>.
/// </summary>
/// <param name="definition">The definition describing the agent to create.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A fully-initialised <see cref="AIAgent"/> ready to participate in the conversation.</returns>
Task<AIAgent> CreateAgentAsync(
ConversationAgentDefinition definition,
CancellationToken cancellationToken = default);
/// <summary>
/// Optionally compacts (reduces) the supplied <paramref name="messages"/>.
/// </summary>
/// <param name="messages">The current list of messages to compact.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>
/// The compacted list of messages, or <see langword="null"/> if no compaction was performed.
/// When <see langword="null"/> is returned the original <paramref name="messages"/> list is used unchanged.
/// </returns>
Task<IList<ChatMessage>?> CompactAsync(
IList<ChatMessage> messages,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,163 @@
# AgentConversation Integration Test Harness
The `AgentConversation.IntegrationTests` project provides a **reusable test harness** for validating conversation dynamics in long-running, multi-agent scenarios that involve tool use. Instead of rebuilding a large conversation context in every test run, the harness allows you to:
1. **Capture** a representative conversation context once (by driving a real conversation with your AI agents).
2. **Serialize** that context to a JSON file and commit it alongside your tests.
3. **Restore** the saved context in each test run.
4. **Solicit** one or more agent responses from the restored context.
5. **Validate** each response and compare before/after metrics.
---
## Key Abstractions
| Type | Role |
|------|------|
| `IConversationTestCase` | Defines a test case: agents, initial messages, ordered steps, and a method to automate context creation. |
| `IConversationTestSystem` | System-specific plugin: how to **create agents** and how to apply **context compaction** (both vary per AI backend). |
| `ConversationAgentDefinition` | Describes a participating agent — name, instructions, and optional tools. |
| `ConversationStep` | One step in the conversation: which agent to invoke, an optional input message, and an optional validation delegate. |
| `ConversationMetrics` | A snapshot of conversation context size — message count and serialized byte size. |
| `ConversationMetricsReport` | A before/after `ConversationMetrics` pair with delta helpers for reporting. |
| `ConversationContextSerializer` | Serializes and deserializes `IList<ChatMessage>` to/from JSON strings or files. |
| `ConversationHarness` | The core runner that ties everything together. |
| `ConversationHarnessTests<TSystem>` | Abstract xunit base class that subclasses inherit to get the `RunAllTestCasesAsync` test. |
---
## How It Works
### 1. Implement `IConversationTestSystem`
Provide an implementation that knows how to create agents for your target AI backend and optionally compact messages:
```csharp
public sealed class OpenAIConversationTestSystem : IConversationTestSystem
{
public Task<AIAgent> CreateAgentAsync(ConversationAgentDefinition definition, CancellationToken ct = default)
{
var chatClient = new OpenAIClient(apiKey)
.GetChatClient("gpt-4o")
.AsIChatClient();
AIAgent agent = new ChatClientAgent(chatClient, options: new()
{
Name = definition.Name,
ChatOptions = new() { Instructions = definition.Instructions, Tools = definition.Tools }
});
return Task.FromResult(agent);
}
public Task<IList<ChatMessage>?> CompactAsync(IList<ChatMessage> messages, CancellationToken ct = default)
{
// Return null for no compaction, or apply an IChatReducer here.
return Task.FromResult<IList<ChatMessage>?>(null);
}
}
```
### 2. Implement `IConversationTestCase`
Define the agents involved, the initial context to restore, and the steps to execute:
```csharp
public sealed class MyConversationTestCase : IConversationTestCase
{
public string Name => "MyConversation";
public IReadOnlyDictionary<string, ConversationAgentDefinition> AgentDefinitions { get; } =
new Dictionary<string, ConversationAgentDefinition>
{
["Assistant"] = new() { Name = "Assistant", Instructions = "You are a helpful assistant." }
};
// Load the saved context from a JSON fixture file.
public IList<ChatMessage> GetInitialMessages() =>
ConversationContextSerializer.LoadFromFile("fixtures/my-conversation.context.json");
public IReadOnlyList<ConversationStep> Steps { get; } =
[
new ConversationStep
{
AgentName = "Assistant",
Input = new ChatMessage(ChatRole.User, "Summarize our conversation so far."),
Validate = (response, metrics) =>
{
Assert.NotEmpty(response.Text);
Assert.True(metrics.After.MessageCount > metrics.Before.MessageCount);
}
}
];
// Called once to generate the fixture file — not during normal CI runs.
public async Task<IList<ChatMessage>> CreateInitialContextAsync(
IReadOnlyDictionary<string, AIAgent> agents, CancellationToken ct = default)
{
var agent = agents["Assistant"];
var session = await agent.CreateSessionAsync(ct);
// Drive a rich, representative conversation to build up the context.
await agent.RunAsync(new ChatMessage(ChatRole.User, "Tell me about the weather."), session, cancellationToken: ct);
await agent.RunAsync(new ChatMessage(ChatRole.User, "What about tomorrow?"), session, cancellationToken: ct);
// ... more turns ...
var provider = agent.GetService<InMemoryChatHistoryProvider>()!;
return provider.GetMessages(session);
}
}
```
### 3. Derive from `ConversationHarnessTests<TSystem>`
Wire the system and test cases into a concrete test class:
```csharp
public class MyConversationTests(ITestOutputHelper output)
: ConversationHarnessTests<OpenAIConversationTestSystem>(output)
{
protected override OpenAIConversationTestSystem CreateTestSystem() => new();
protected override IEnumerable<IConversationTestCase> GetTestCases() =>
[
new MyConversationTestCase(),
];
}
```
The inherited `RunAllTestCasesAsync` test method will automatically run all cases and log the before/after metrics to the xunit test output.
---
## Generating Initial Context Fixtures
The context fixture files need to be generated once and committed to the repository. Run the inherited `SerializeAllInitialContextsAsync` test to produce them:
```bash
dotnet test --filter "FullyQualifiedName~SerializeAllInitialContexts"
```
This test is **skipped during normal CI runs** to avoid expensive AI calls. After generating the files, commit them alongside your test code so that all subsequent runs can restore the context without calling the AI service again.
---
## Metrics Reporting
After each test case runs, a `ConversationMetricsReport` is logged. It captures:
- **`Before`** — message count and serialized byte size of the initial context.
- **`After`** — message count and byte size after all steps have executed.
- **`MessageCountDelta`** / **`SizeDeltaBytes`** — the change between before and after.
Example output:
```
[MyConversation] Before=[Messages=12, Size=4096B] After=[Messages=14, Size=4712B] Delta=[Messages=+2, Size=+616B]
```
---
## Example
See [`Microsoft.Agents.AI.Abstractions.IntegrationTests`](../Microsoft.Agents.AI.Abstractions.IntegrationTests) for a self-contained working example that uses an in-memory mock `IChatClient` so it runs without live AI credentials.
@@ -0,0 +1,36 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using AgentConversation.IntegrationTests;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Abstractions.IntegrationTests;
/// <summary>
/// Example integration tests that exercise the <see cref="ConversationHarness"/> using an
/// in-memory <see cref="InMemoryConversationTestSystem"/> that does not require live AI credentials.
/// </summary>
/// <remarks>
/// <para>
/// This class derives from <see cref="ConversationHarnessTests{TSystem}"/> and provides the system
/// and test cases. The <see cref="ConversationHarnessTests{TSystem}.RunAllTestCasesAsync"/> test
/// method is inherited automatically and will run all cases returned by <see cref="GetTestCases"/>.
/// </para>
/// <para>
/// To adapt these tests to a real AI backend, replace <see cref="InMemoryConversationTestSystem"/>
/// with an implementation that constructs agents backed by your AI service.
/// </para>
/// </remarks>
public class AgentConversationHarnessTests(ITestOutputHelper output)
: ConversationHarnessTests<InMemoryConversationTestSystem>(output)
{
/// <inheritdoc />
protected override InMemoryConversationTestSystem CreateTestSystem() =>
new InMemoryConversationTestSystem();
/// <inheritdoc />
protected override IEnumerable<IConversationTestCase> GetTestCases() =>
[
new MenuConversationTestCase(),
];
}
@@ -0,0 +1,75 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using AgentConversation.IntegrationTests;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.Abstractions.IntegrationTests;
/// <summary>
/// An example <see cref="IConversationTestSystem"/> that uses an in-memory mock <see cref="IChatClient"/>
/// so the harness can be exercised without live AI service credentials.
/// </summary>
/// <remarks>
/// In a real integration test against a live AI service (e.g., OpenAI Chat Completion), this class
/// would be replaced with an implementation that constructs <see cref="ChatClientAgent"/> instances
/// backed by the real <see cref="IChatClient"/>. The compaction contract can similarly be wired up
/// to an <see cref="Microsoft.Extensions.AI.IChatReducer"/> of your choice.
/// </remarks>
public sealed class InMemoryConversationTestSystem : IConversationTestSystem
{
/// <summary>
/// A deterministic response suffix appended by the mock chat client to every assistant reply.
/// Test validations can assert on this value to confirm the mock was invoked.
/// </summary>
public const string MockResponseSuffix = "[mock-response]";
/// <inheritdoc />
public Task<AIAgent> CreateAgentAsync(
ConversationAgentDefinition definition,
CancellationToken cancellationToken = default)
{
// Create a mock IChatClient that returns a deterministic response.
var mockClient = new Mock<IChatClient>();
mockClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(() => new ChatResponse(
new ChatMessage(ChatRole.Assistant,
$"Here are today's specials: Clam Chowder, Cobb Salad, Chai Tea. {MockResponseSuffix}")));
// GetService is called internally by the harness for metadata; return null for unknown types.
mockClient
.Setup(c => c.GetService(It.IsAny<System.Type>(), It.IsAny<object?>()))
.Returns((System.Type _, object? _) => null);
AIAgent agent = new ChatClientAgent(
mockClient.Object,
options: new ChatClientAgentOptions
{
Name = definition.Name,
ChatOptions = new ChatOptions
{
Instructions = definition.Instructions,
Tools = definition.Tools is not null ? new System.Collections.Generic.List<AITool>(definition.Tools) : null,
}
});
return Task.FromResult(agent);
}
/// <inheritdoc />
public Task<IList<ChatMessage>?> CompactAsync(
IList<ChatMessage> messages,
CancellationToken cancellationToken = default)
{
// No compaction in this example system.
return Task.FromResult<IList<ChatMessage>?>(null);
}
}
@@ -0,0 +1,91 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using AgentConversation.IntegrationTests;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Abstractions.IntegrationTests;
/// <summary>
/// An example <see cref="IConversationTestCase"/> that validates the harness can restore a
/// pre-built conversation context and solicit a response from an agent.
/// </summary>
/// <remarks>
/// This test case uses a fixed, in-memory conversation representing a menu-ordering interaction.
/// The messages are defined inline (no JSON fixture file is required), which makes this a
/// self-contained example that runs without live AI credentials.
/// </remarks>
public sealed class MenuConversationTestCase : IConversationTestCase
{
private const string AgentKey = "MenuAgent";
/// <inheritdoc />
public string Name => "MenuConversation";
/// <inheritdoc />
public IReadOnlyDictionary<string, ConversationAgentDefinition> AgentDefinitions { get; } =
new Dictionary<string, ConversationAgentDefinition>
{
[AgentKey] = new ConversationAgentDefinition
{
Name = AgentKey,
Instructions = "You are a helpful restaurant assistant. Answer questions about the menu.",
Tools =
[
AIFunctionFactory.Create(MenuTools.GetSpecials),
AIFunctionFactory.Create(MenuTools.GetItemPrice),
]
}
};
/// <inheritdoc />
public IReadOnlyList<ConversationStep> Steps { get; } =
[
new ConversationStep
{
AgentName = AgentKey,
Input = new ChatMessage(ChatRole.User, "What are the specials today?"),
Validate = (response, metrics) =>
{
Assert.NotNull(response);
Assert.NotEmpty(response.Text);
Assert.True(metrics.After.MessageCount > metrics.Before.MessageCount,
"Message count should grow after the step.");
}
}
];
/// <inheritdoc />
public IList<ChatMessage> GetInitialMessages() =>
// A short, representative conversation context that is already in memory.
[
new ChatMessage(ChatRole.User, "Hello, I'd like to see the menu."),
new ChatMessage(ChatRole.Assistant, "Welcome! I'm happy to help you with our menu. Feel free to ask about today's specials or the price of any item."),
];
/// <inheritdoc />
public async Task<IList<ChatMessage>> CreateInitialContextAsync(
IReadOnlyDictionary<string, AIAgent> agents,
CancellationToken cancellationToken = default)
{
// Build the initial context by running a short greeting exchange.
var agent = agents[AgentKey];
var session = await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
await agent.RunAsync(
new ChatMessage(ChatRole.User, "Hello, I'd like to see the menu."),
session,
cancellationToken: cancellationToken).ConfigureAwait(false);
var historyProvider = agent.GetService<ChatHistoryProvider>() as InMemoryChatHistoryProvider;
if (historyProvider is not null)
{
return historyProvider.GetMessages(session);
}
return GetInitialMessages();
}
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
namespace Microsoft.Agents.AI.Abstractions.IntegrationTests;
/// <summary>
/// Example tools used by the <see cref="MenuConversationTestCase"/> to simulate a restaurant menu service.
/// </summary>
internal static class MenuTools
{
[Description("Provides a list of today's specials from the restaurant menu.")]
public static string GetSpecials() =>
"""
Special Soup: Clam Chowder
Special Salad: Cobb Salad
Special Drink: Chai Tea
""";
[Description("Provides the price of the requested menu item.")]
public static string GetItemPrice(
[Description("The name of the menu item.")] string menuItem) => "$9.99";
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectRequiredMemberOnLegacy>true</InjectRequiredMemberOnLegacy>
<InjectCompilerFeatureRequiredOnLegacy>true</InjectCompilerFeatureRequiredOnLegacy>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\AgentConversation.IntegrationTests\AgentConversation.IntegrationTests.csproj" />
</ItemGroup>
</Project>