Add ConversationDynamics.IntegrationTests harness and add to solution

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-03 19:39:46 +00:00
Unverified
parent d0c097941a
commit 1b4c59ea38
11 changed files with 676 additions and 0 deletions
+1
View File
@@ -464,6 +464,7 @@
<Folder Name="/Tests/" />
<Folder Name="/Tests/IntegrationTests/">
<Project Path="tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj" />
<Project Path="tests/ConversationDynamics.IntegrationTests/ConversationDynamics.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,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace ConversationDynamics.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 ConversationDynamics.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,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,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 ConversationDynamics.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 ConversationDynamics.IntegrationTests;
/// <summary>
/// Abstract xunit base class for conversation dynamics 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 ConversationDynamics.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 ConversationDynamics.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 ConversationDynamics.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 ConversationDynamics.IntegrationTests;
/// <summary>
/// Defines a single conversation dynamics 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 ConversationDynamics.IntegrationTests;
/// <summary>
/// Abstracts the system-specific concerns of a conversation dynamics 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);
}