Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions dotnet/agent-framework-dotnet.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
Expand Down
1 change: 1 addition & 0 deletions dotnet/tests/AgentConversation.IntegrationTests/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
bin/
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -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; }
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
214 changes: 214 additions & 0 deletions dotnet/tests/AgentConversation.IntegrationTests/ConversationHarness.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading