Skip to content

Commit 1b4c59e

Browse files
Copilotcrickman
andcommitted
Add ConversationDynamics.IntegrationTests harness and add to solution
Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>
1 parent d0c0979 commit 1b4c59e

11 files changed

Lines changed: 676 additions & 0 deletions

dotnet/agent-framework-dotnet.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,7 @@
464464
<Folder Name="/Tests/" />
465465
<Folder Name="/Tests/IntegrationTests/">
466466
<Project Path="tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj" />
467+
<Project Path="tests/ConversationDynamics.IntegrationTests/ConversationDynamics.IntegrationTests.csproj" />
467468
<Project Path="tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletion.IntegrationTests.csproj" />
468469
<Project Path="tests/AzureAI.IntegrationTests/AzureAI.IntegrationTests.csproj" />
469470
<Project Path="tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj" />
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Collections.Generic;
4+
using Microsoft.Extensions.AI;
5+
6+
namespace ConversationDynamics.IntegrationTests;
7+
8+
/// <summary>
9+
/// Defines an agent participating in a <see cref="IConversationTestCase"/>.
10+
/// </summary>
11+
public sealed class ConversationAgentDefinition
12+
{
13+
/// <summary>
14+
/// Gets or sets the unique name identifying this agent within the test case.
15+
/// </summary>
16+
public required string Name { get; init; }
17+
18+
/// <summary>
19+
/// Gets or sets the system instructions for the agent.
20+
/// </summary>
21+
public string Instructions { get; init; } = "You are a helpful assistant.";
22+
23+
/// <summary>
24+
/// Gets or sets the optional list of tools available to the agent.
25+
/// </summary>
26+
public IList<AITool>? Tools { get; init; }
27+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using System.IO;
6+
using System.Text.Json;
7+
using Microsoft.Agents.AI;
8+
using Microsoft.Extensions.AI;
9+
10+
namespace ConversationDynamics.IntegrationTests;
11+
12+
/// <summary>
13+
/// Provides helpers for serializing and deserializing conversation contexts (lists of <see cref="ChatMessage"/>)
14+
/// to and from JSON, enabling the initial context of a test case to be captured once and reused across runs.
15+
/// </summary>
16+
public static class ConversationContextSerializer
17+
{
18+
private static readonly JsonSerializerOptions s_serializerOptions = AgentAbstractionsJsonUtilities.DefaultOptions;
19+
20+
/// <summary>
21+
/// Serializes a list of <see cref="ChatMessage"/> instances to a JSON string.
22+
/// </summary>
23+
/// <param name="messages">The messages to serialize.</param>
24+
/// <returns>A JSON string representation of the messages.</returns>
25+
public static string Serialize(IList<ChatMessage> messages) =>
26+
JsonSerializer.Serialize(messages, s_serializerOptions);
27+
28+
/// <summary>
29+
/// Deserializes a JSON string into a list of <see cref="ChatMessage"/> instances.
30+
/// </summary>
31+
/// <param name="json">The JSON string to deserialize.</param>
32+
/// <returns>The deserialized list of messages.</returns>
33+
/// <exception cref="InvalidOperationException">
34+
/// Thrown when the JSON cannot be deserialized into a list of <see cref="ChatMessage"/> instances.
35+
/// </exception>
36+
public static IList<ChatMessage> Deserialize(string json)
37+
{
38+
var messages = JsonSerializer.Deserialize<List<ChatMessage>>(json, s_serializerOptions);
39+
return messages ?? throw new InvalidOperationException("Failed to deserialize chat messages from the provided JSON.");
40+
}
41+
42+
/// <summary>
43+
/// Saves a list of <see cref="ChatMessage"/> instances to a JSON file.
44+
/// </summary>
45+
/// <param name="filePath">The path of the file to write.</param>
46+
/// <param name="messages">The messages to save.</param>
47+
public static void SaveToFile(string filePath, IList<ChatMessage> messages)
48+
{
49+
var json = Serialize(messages);
50+
File.WriteAllText(filePath, json);
51+
}
52+
53+
/// <summary>
54+
/// Loads a list of <see cref="ChatMessage"/> instances from a JSON file.
55+
/// </summary>
56+
/// <param name="filePath">The path of the file to read.</param>
57+
/// <returns>The deserialized list of messages.</returns>
58+
/// <exception cref="FileNotFoundException">Thrown when <paramref name="filePath"/> does not exist.</exception>
59+
public static IList<ChatMessage> LoadFromFile(string filePath)
60+
{
61+
if (!File.Exists(filePath))
62+
{
63+
throw new FileNotFoundException($"Conversation context file not found: '{filePath}'. " +
64+
"Run the context creation step first to generate this file.", filePath);
65+
}
66+
67+
var json = File.ReadAllText(filePath);
68+
return Deserialize(json);
69+
}
70+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<IsTestProject>false</IsTestProject>
5+
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
6+
<InjectRequiredMemberOnLegacy>true</InjectRequiredMemberOnLegacy>
7+
<InjectCompilerFeatureRequiredOnLegacy>true</InjectCompilerFeatureRequiredOnLegacy>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
12+
</ItemGroup>
13+
14+
</Project>
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Linq;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
using Microsoft.Agents.AI;
9+
using Microsoft.Extensions.AI;
10+
11+
namespace ConversationDynamics.IntegrationTests;
12+
13+
/// <summary>
14+
/// Orchestrates the execution of a <see cref="IConversationTestCase"/> against a given
15+
/// <see cref="IConversationTestSystem"/>: restores the conversation context, optionally runs compaction,
16+
/// executes each step, captures before/after metrics, and runs per-step validations.
17+
/// </summary>
18+
public sealed class ConversationHarness
19+
{
20+
private readonly IConversationTestSystem _system;
21+
22+
/// <summary>
23+
/// Initializes a new instance of <see cref="ConversationHarness"/>.
24+
/// </summary>
25+
/// <param name="system">The system under test that provides agent creation and compaction.</param>
26+
public ConversationHarness(IConversationTestSystem system)
27+
{
28+
if (system is null)
29+
{
30+
throw new ArgumentNullException(nameof(system));
31+
}
32+
33+
this._system = system;
34+
}
35+
36+
/// <summary>
37+
/// Runs the supplied <paramref name="testCase"/> from its serialized initial context, executing
38+
/// every <see cref="ConversationStep"/> in order and returning the combined metrics report.
39+
/// </summary>
40+
/// <param name="testCase">The test case to execute.</param>
41+
/// <param name="cancellationToken">A token to cancel the operation.</param>
42+
/// <returns>
43+
/// A <see cref="ConversationMetricsReport"/> describing the before-and-after state of the
44+
/// conversation context across all steps.
45+
/// </returns>
46+
/// <exception cref="ArgumentNullException"><paramref name="testCase"/> is <see langword="null"/>.</exception>
47+
/// <exception cref="InvalidOperationException">
48+
/// Thrown when a step references an agent name that is not present in <see cref="IConversationTestCase.AgentDefinitions"/>.
49+
/// </exception>
50+
public async Task<ConversationMetricsReport> RunAsync(
51+
IConversationTestCase testCase,
52+
CancellationToken cancellationToken = default)
53+
{
54+
if (testCase is null)
55+
{
56+
throw new ArgumentNullException(nameof(testCase));
57+
}
58+
59+
// 1. Restore the initial context.
60+
var initialMessages = testCase.GetInitialMessages();
61+
62+
// 2. Capture "before" metrics.
63+
var beforeMetrics = MeasureMetrics(initialMessages);
64+
65+
// 3. Create the agents defined for this test case.
66+
var agents = new Dictionary<string, AIAgent>(StringComparer.Ordinal);
67+
foreach (var entry in testCase.AgentDefinitions)
68+
{
69+
agents[entry.Key] = await this._system.CreateAgentAsync(entry.Value, cancellationToken).ConfigureAwait(false);
70+
}
71+
72+
// 4. Create sessions and restore the initial messages for each agent.
73+
var sessions = new Dictionary<string, AgentSession>(StringComparer.Ordinal);
74+
foreach (var entry in agents)
75+
{
76+
var session = await entry.Value.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
77+
RestoreMessages(entry.Value, session, initialMessages);
78+
sessions[entry.Key] = session;
79+
}
80+
81+
// 5. Optionally compact the messages.
82+
var compacted = await this._system.CompactAsync(initialMessages, cancellationToken).ConfigureAwait(false);
83+
if (compacted is not null)
84+
{
85+
// Apply the compacted history to all agent sessions.
86+
foreach (var entry in agents)
87+
{
88+
RestoreMessages(entry.Value, sessions[entry.Key], compacted);
89+
}
90+
}
91+
92+
// 6. Execute each step.
93+
foreach (var step in testCase.Steps)
94+
{
95+
if (!agents.TryGetValue(step.AgentName, out var agent))
96+
{
97+
throw new InvalidOperationException(
98+
$"Step references agent '{step.AgentName}' which is not defined in the test case. " +
99+
$"Defined agents: {string.Join(", ", agents.Keys)}");
100+
}
101+
102+
var session = sessions[step.AgentName];
103+
AgentResponse response;
104+
105+
if (step.Input is not null)
106+
{
107+
response = await agent.RunAsync(step.Input, session, cancellationToken: cancellationToken).ConfigureAwait(false);
108+
}
109+
else
110+
{
111+
response = await agent.RunAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false);
112+
}
113+
114+
// 7. Capture "after" metrics for this step and run the step's validation.
115+
var currentMessages = GetCurrentMessages(agent, sessions[step.AgentName], initialMessages, compacted);
116+
var afterMetrics = MeasureMetrics(currentMessages);
117+
var metricsReport = new ConversationMetricsReport
118+
{
119+
Before = beforeMetrics,
120+
After = afterMetrics,
121+
};
122+
123+
step.Validate?.Invoke(response, metricsReport);
124+
}
125+
126+
// 8. Capture the final "after" metrics from the first agent's session.
127+
var firstAgent = agents.Values.First();
128+
var firstSession = sessions[agents.Keys.First()];
129+
var finalMessages = GetCurrentMessages(firstAgent, firstSession, initialMessages, compacted);
130+
var finalAfterMetrics = MeasureMetrics(finalMessages);
131+
132+
return new ConversationMetricsReport
133+
{
134+
Before = beforeMetrics,
135+
After = finalAfterMetrics,
136+
};
137+
}
138+
139+
/// <summary>
140+
/// Drives a conversation with the agents defined in <paramref name="testCase"/> to produce the initial
141+
/// context, then serializes that context to <paramref name="outputFilePath"/>.
142+
/// </summary>
143+
/// <remarks>
144+
/// This method should be called once (outside of normal test execution) to generate the fixture
145+
/// data that tests will subsequently restore via <see cref="IConversationTestCase.GetInitialMessages"/>.
146+
/// </remarks>
147+
/// <param name="testCase">The test case whose initial context should be created.</param>
148+
/// <param name="outputFilePath">The file path to write the serialized context to.</param>
149+
/// <param name="cancellationToken">A token to cancel the operation.</param>
150+
public async Task SerializeInitialContextAsync(
151+
IConversationTestCase testCase,
152+
string outputFilePath,
153+
CancellationToken cancellationToken = default)
154+
{
155+
if (testCase is null)
156+
{
157+
throw new ArgumentNullException(nameof(testCase));
158+
}
159+
160+
if (string.IsNullOrEmpty(outputFilePath))
161+
{
162+
throw new ArgumentException("Output file path must not be null or empty.", nameof(outputFilePath));
163+
}
164+
165+
// Create agents for context generation.
166+
var agents = new Dictionary<string, AIAgent>(StringComparer.Ordinal);
167+
foreach (var entry in testCase.AgentDefinitions)
168+
{
169+
agents[entry.Key] = await this._system.CreateAgentAsync(entry.Value, cancellationToken).ConfigureAwait(false);
170+
}
171+
172+
var messages = await testCase.CreateInitialContextAsync(agents, cancellationToken).ConfigureAwait(false);
173+
ConversationContextSerializer.SaveToFile(outputFilePath, messages);
174+
}
175+
176+
// -------------------------------------------------------------------------
177+
// Private helpers
178+
// -------------------------------------------------------------------------
179+
180+
private static ConversationMetrics MeasureMetrics(IList<ChatMessage> messages)
181+
{
182+
var serialized = ConversationContextSerializer.Serialize(messages);
183+
return new ConversationMetrics
184+
{
185+
MessageCount = messages.Count,
186+
SerializedSizeBytes = System.Text.Encoding.UTF8.GetByteCount(serialized),
187+
};
188+
}
189+
190+
private static void RestoreMessages(AIAgent agent, AgentSession session, IList<ChatMessage> messages)
191+
{
192+
// InMemoryChatHistoryProvider is the standard history provider for ChatClientAgent.
193+
// When found, load the messages directly into the provider's state for this session.
194+
if (agent.GetService<ChatHistoryProvider>() is InMemoryChatHistoryProvider memProvider)
195+
{
196+
memProvider.SetMessages(session, messages.ToList());
197+
}
198+
}
199+
200+
private static IList<ChatMessage> GetCurrentMessages(
201+
AIAgent agent,
202+
AgentSession session,
203+
IList<ChatMessage> fallbackInitial,
204+
IList<ChatMessage>? compacted)
205+
{
206+
if (agent.GetService<ChatHistoryProvider>() is InMemoryChatHistoryProvider memProvider)
207+
{
208+
return memProvider.GetMessages(session);
209+
}
210+
211+
// Fall back to the compacted (or original) initial messages when the provider is unavailable.
212+
return compacted ?? fallbackInitial;
213+
}
214+
}

0 commit comments

Comments
 (0)