.NET: Fix bug with per-service-call persistence and approvals (#4933)

* Fix bug with per-service-call persistence and approvals

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
westey
2026-03-26 17:45:46 +00:00
committed by GitHub
Unverified
parent 63dee91a5f
commit 3585581c7a
8 changed files with 971 additions and 16 deletions
@@ -788,6 +788,13 @@ public sealed partial class ChatClientAgent : AIAgent
chatOptions.ConversationId = typedSession.ConversationId;
}
// When per-service-call persistence is active, set a sentinel conversation ID so that
// FunctionInvokingChatClient treats locally-persisted history the same as service-managed
// history. This prevents it from adding duplicate FunctionCallContent messages into the
// request when processing approval responses — the loaded history already contains them.
// ChatHistoryPersistingChatClient strips the sentinel before forwarding to the inner client.
chatOptions = this.SetLocalHistoryConversationIdIfNeeded(chatOptions);
// Materialize the accumulated messages once at the end of the provider pipeline, reusing the existing list if possible.
List<ChatMessage> messagesList = inputMessagesForChatClient as List<ChatMessage> ?? inputMessagesForChatClient.ToList();
@@ -929,6 +936,26 @@ public sealed partial class ChatClientAgent : AIAgent
}
}
/// <summary>
/// Sets the <see cref="ChatHistoryPersistingChatClient.LocalHistoryConversationId"/> sentinel on
/// <paramref name="chatOptions"/> when per-service-call persistence is active and no real
/// conversation ID is present.
/// </summary>
/// <returns>
/// The (possibly new) <see cref="ChatOptions"/> with the sentinel set, or the original
/// <paramref name="chatOptions"/> if no sentinel is needed.
/// </returns>
private ChatOptions? SetLocalHistoryConversationIdIfNeeded(ChatOptions? chatOptions)
{
if (this.PersistsChatHistoryPerServiceCall && string.IsNullOrWhiteSpace(chatOptions?.ConversationId))
{
chatOptions ??= new ChatOptions();
chatOptions.ConversationId = ChatHistoryPersistingChatClient.LocalHistoryConversationId;
}
return chatOptions;
}
/// <summary>
/// Gets a value indicating whether the agent has a <see cref="ChatHistoryPersistingChatClient"/>
/// decorator in mark-only mode, which marks messages for later persistence at the end of the run.
@@ -50,6 +50,26 @@ internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient
/// </summary>
internal const string PersistedMarkerKey = "_chatHistoryPersisted";
/// <summary>
/// A sentinel value set on <see cref="ChatOptions.ConversationId"/> by <see cref="ChatClientAgent"/>
/// when per-service-call persistence is active and no real conversation ID exists.
/// </summary>
/// <remarks>
/// <para>
/// This signals to <see cref="FunctionInvokingChatClient"/> that the chat history is being managed
/// externally (by this decorator), which prevents it from adding duplicate <see cref="FunctionCallContent"/>
/// messages into the request during approval-response processing. Without this sentinel,
/// <see cref="FunctionInvokingChatClient"/> would reconstruct function-call messages from approval
/// responses and append them to the original messages — but the loaded history already contains
/// those same function calls, causing duplicate tool-call entries that the model rejects.
/// </para>
/// <para>
/// This decorator strips the sentinel before forwarding requests to the inner client, so the
/// underlying model never sees it.
/// </para>
/// </remarks>
internal const string LocalHistoryConversationId = "_agent_local_history";
/// <summary>
/// Initializes a new instance of the <see cref="ChatHistoryPersistingChatClient"/> class.
/// </summary>
@@ -87,6 +107,7 @@ internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient
CancellationToken cancellationToken = default)
{
var (agent, session) = GetRequiredAgentAndSession();
options = StripLocalHistoryConversationId(options);
ChatResponse response;
try
@@ -130,6 +151,7 @@ internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var (agent, session) = GetRequiredAgentAndSession();
options = StripLocalHistoryConversationId(options);
List<ChatResponseUpdate> responseUpdates = [];
@@ -310,4 +332,20 @@ internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient
}
}
}
/// <summary>
/// If the <paramref name="options"/> carry the <see cref="LocalHistoryConversationId"/> sentinel,
/// returns a clone with the conversation ID cleared so the inner client never sees it.
/// Otherwise returns the original <paramref name="options"/> unchanged.
/// </summary>
private static ChatOptions? StripLocalHistoryConversationId(ChatOptions? options)
{
if (options?.ConversationId == LocalHistoryConversationId)
{
options = options.Clone();
options.ConversationId = null;
}
return options;
}
}
@@ -0,0 +1,259 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Shared test helper for <see cref="ChatClientAgent"/> integration tests that verify
/// end-to-end behavior with <see cref="ChatHistoryPersistingChatClient"/> and
/// <see cref="FunctionInvokingChatClient"/>.
/// </summary>
internal static class ChatClientAgentTestHelper
{
/// <summary>
/// Represents an expected service call during a test: an optional input verifier and the response to return.
/// </summary>
/// <param name="Response">The <see cref="ChatResponse"/> the mock service should return for this call.</param>
/// <param name="VerifyInput">Optional callback to verify the messages sent to the service on this call.</param>
#pragma warning disable CA1812 // Instantiated by test classes
public sealed record ServiceCallExpectation(
ChatResponse Response,
Action<List<ChatMessage>>? VerifyInput = null);
#pragma warning restore CA1812
/// <summary>
/// Describes the expected shape of a message in the persisted history for structural comparison.
/// </summary>
/// <param name="Role">The expected role of the message.</param>
/// <param name="TextContains">Optional substring that the message text should contain.</param>
/// <param name="ContentTypes">Optional array of expected <see cref="AIContent"/> types in the message.</param>
#pragma warning disable CA1812 // Instantiated by test classes
public sealed record ExpectedMessage(
ChatRole Role,
string? TextContains = null,
Type[]? ContentTypes = null);
#pragma warning restore CA1812
/// <summary>
/// The result of a RunAsync invocation, containing the response, session, agent,
/// captured service inputs, and call counts for detailed verification.
/// </summary>
public sealed record RunResult(
AgentResponse Response,
ChatClientAgentSession Session,
ChatClientAgent Agent,
Mock<IChatClient> MockService,
int TotalServiceCalls,
List<List<ChatMessage>> CapturedServiceInputs);
/// <summary>
/// Creates a mock <see cref="IChatClient"/> that returns responses in sequence,
/// captures input messages, and optionally verifies inputs.
/// </summary>
/// <param name="expectations">The ordered sequence of expected service calls.</param>
/// <param name="callIndex">Shared call index counter (allows reuse across multiple RunAsync calls).</param>
/// <param name="capturedInputs">List that captured service inputs are appended to.</param>
/// <returns>The configured mock.</returns>
public static Mock<IChatClient> CreateSequentialMock(
List<ServiceCallExpectation> expectations,
Ref<int> callIndex,
List<List<ChatMessage>> capturedInputs)
{
Mock<IChatClient> mock = new();
mock.Setup(s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((msgs, _, _) =>
{
int idx = callIndex.Value++;
var messageList = msgs.ToList();
capturedInputs.Add(messageList);
if (idx >= expectations.Count)
{
throw new InvalidOperationException(
$"Mock received unexpected service call #{idx + 1}. Only {expectations.Count} call(s) were expected.");
}
var expectation = expectations[idx];
expectation.VerifyInput?.Invoke(messageList);
return Task.FromResult(expectation.Response);
});
return mock;
}
/// <summary>
/// Runs the agent with the given inputs, automatically verifying service call count
/// and optional expected history, and returns the result for further assertions.
/// </summary>
/// <param name="inputMessages">Messages to pass to RunAsync.</param>
/// <param name="serviceCallExpectations">Ordered service call expectations for the mock.</param>
/// <param name="agentOptions">Options for configuring the agent. If null, defaults are used.</param>
/// <param name="existingSession">An existing session to reuse (for multi-turn tests). If null, a new session is created.</param>
/// <param name="existingAgent">An existing agent to reuse (for multi-turn tests). If null, a new agent is created.</param>
/// <param name="existingMock">An existing mock to reuse (for multi-turn tests). If null, a new mock is created.</param>
/// <param name="callIndex">Shared call index for multi-turn tests. If null, a new counter is created.</param>
/// <param name="capturedInputs">Shared captured inputs list for multi-turn tests. If null, a new list is created.</param>
/// <param name="initialChatHistory">Optional initial chat history to pre-populate in <see cref="InMemoryChatHistoryProvider"/>.</param>
/// <param name="runOptions">Optional <see cref="AgentRunOptions"/> to pass to RunAsync.</param>
/// <param name="expectedServiceCallCount">
/// If provided, asserts the total number of service calls matches.
/// For multi-turn tests, pass null and verify after the final turn.
/// </param>
/// <param name="expectedHistory">
/// If provided, asserts that the persisted history matches these expected messages.
/// For multi-turn tests, pass null and verify after the final turn.
/// </param>
/// <returns>A <see cref="RunResult"/> containing the response, session, agent, mock, and captured inputs.</returns>
public static async Task<RunResult> RunAsync(
List<ChatMessage> inputMessages,
List<ServiceCallExpectation> serviceCallExpectations,
ChatClientAgentOptions? agentOptions = null,
ChatClientAgentSession? existingSession = null,
ChatClientAgent? existingAgent = null,
Mock<IChatClient>? existingMock = null,
Ref<int>? callIndex = null,
List<List<ChatMessage>>? capturedInputs = null,
List<ChatMessage>? initialChatHistory = null,
AgentRunOptions? runOptions = null,
int? expectedServiceCallCount = null,
List<ExpectedMessage>? expectedHistory = null)
{
callIndex ??= new Ref<int>(0);
capturedInputs ??= [];
var mock = existingMock ?? CreateSequentialMock(serviceCallExpectations, callIndex, capturedInputs);
agentOptions ??= new ChatClientAgentOptions();
var agent = existingAgent ?? new ChatClientAgent(
mock.Object,
options: agentOptions,
services: new ServiceCollection().BuildServiceProvider());
var session = existingSession ?? (await agent.CreateSessionAsync() as ChatClientAgentSession)!;
// Pre-populate initial chat history if provided.
if (initialChatHistory is not null)
{
(agent.ChatHistoryProvider as InMemoryChatHistoryProvider)
?.SetMessages(session, new List<ChatMessage>(initialChatHistory));
}
var response = await agent.RunAsync(inputMessages, session, runOptions);
var result = new RunResult(response, session, agent, mock, callIndex.Value, capturedInputs);
// Auto-verify service call count if specified.
if (expectedServiceCallCount.HasValue)
{
Assert.Equal(expectedServiceCallCount.Value, callIndex.Value);
}
// Auto-verify persisted history if specified.
if (expectedHistory is not null)
{
var history = GetPersistedHistory(agent, session);
AssertMessagesMatch(history, expectedHistory);
}
return result;
}
/// <summary>
/// Asserts that the actual message list matches the expected message patterns structurally.
/// Checks message count, roles, optional text content, and optional content types.
/// </summary>
/// <param name="actual">The actual messages to verify.</param>
/// <param name="expected">The expected message patterns.</param>
public static void AssertMessagesMatch(List<ChatMessage> actual, List<ExpectedMessage> expected)
{
Assert.True(
expected.Count == actual.Count,
$"Expected {expected.Count} message(s) but found {actual.Count}.\nActual messages:\n{FormatMessages(actual)}");
for (int i = 0; i < expected.Count; i++)
{
var exp = expected[i];
var act = actual[i];
Assert.True(
exp.Role == act.Role,
$"Message [{i}]: expected role {exp.Role} but found {act.Role}.\nActual messages:\n{FormatMessages(actual)}");
if (exp.TextContains is not null)
{
Assert.Contains(exp.TextContains, act.Text, StringComparison.Ordinal);
}
if (exp.ContentTypes is not null)
{
AssertContentTypes(act.Contents, exp.ContentTypes, i);
}
}
}
/// <summary>
/// Gets the persisted chat history from the agent's <see cref="InMemoryChatHistoryProvider"/>.
/// </summary>
/// <param name="agent">The agent whose history provider to query.</param>
/// <param name="session">The session to get history for.</param>
/// <returns>The list of persisted messages, or an empty list if no provider is available.</returns>
public static List<ChatMessage> GetPersistedHistory(ChatClientAgent agent, AgentSession session)
{
var provider = agent.ChatHistoryProvider as InMemoryChatHistoryProvider;
return provider?.GetMessages(session) ?? [];
}
/// <summary>
/// Formats the contents of a message list as a diagnostic string for test failure messages.
/// </summary>
/// <param name="messages">The messages to format.</param>
/// <returns>A human-readable representation of the messages.</returns>
public static string FormatMessages(IEnumerable<ChatMessage> messages)
{
var sb = new StringBuilder();
int index = 0;
foreach (var msg in messages)
{
sb.AppendLine($" [{index}] Role={msg.Role}, Text=\"{msg.Text}\", Contents=[{string.Join(", ", msg.Contents.Select(c => c.GetType().Name))}]");
index++;
}
return sb.ToString();
}
/// <summary>
/// A simple mutable reference wrapper for value types, allowing shared state across callbacks.
/// </summary>
public sealed class Ref<T>(T value) where T : struct
{
public T Value { get; set; } = value;
}
/// <summary>
/// Asserts that a message's content collection contains the expected content types.
/// </summary>
private static void AssertContentTypes(IList<AIContent> contents, Type[] expectedTypes, int messageIndex)
{
Assert.True(
contents.Count >= expectedTypes.Length,
$"Message [{messageIndex}]: expected at least {expectedTypes.Length} content(s) but found {contents.Count}. " +
$"Actual types: [{string.Join(", ", contents.Select(c => c.GetType().Name))}]");
foreach (var expectedType in expectedTypes)
{
Assert.True(
contents.Any(c => expectedType.IsInstanceOfType(c)),
$"Message [{messageIndex}]: expected content of type {expectedType.Name} but found [{string.Join(", ", contents.Select(c => c.GetType().Name))}]");
}
}
}
@@ -379,18 +379,23 @@ public partial class ChatClientAgentTests
}
/// <summary>
/// Verify that RunAsync passes null ChatOptions when using regular AgentRunOptions.
/// Verify that RunAsync passes ChatOptions with null ConversationId when using regular AgentRunOptions.
/// When per-service-call persistence is active (default), the sentinel conversation ID is set on ChatOptions
/// and then stripped by ChatHistoryPersistingChatClient before reaching the inner client.
/// </summary>
[Fact]
public async Task RunAsyncPassesNullChatOptionsWhenUsingRegularAgentRunOptionsAsync()
public async Task RunAsyncPassesChatOptionsWithNullConversationIdWhenUsingRegularAgentRunOptionsAsync()
{
// Arrange
ChatOptions? capturedOptions = null;
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
null,
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object);
var runOptions = new AgentRunOptions();
@@ -398,13 +403,9 @@ public partial class ChatClientAgentTests
// Act
await agent.RunAsync([new(ChatRole.User, "test")], options: runOptions);
// Assert
mockService.Verify(
x => x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
null,
It.IsAny<CancellationToken>()),
Times.Once);
// Assert — the inner client receives ChatOptions with null ConversationId (sentinel was stripped)
Assert.NotNull(capturedOptions);
Assert.Null(capturedOptions!.ConversationId);
}
/// <summary>
@@ -0,0 +1,306 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Contains unit tests that verify the end-to-end approval flow behavior of the
/// <see cref="ChatClientAgent"/> class with <see cref="ChatHistoryPersistingChatClient"/>,
/// ensuring that chat history is correctly persisted across multi-turn approval interactions.
/// </summary>
public class ChatClientAgent_ApprovalsTests
{
#region Per-Service-Call Persistence Approval Tests
/// <summary>
/// Verifies that with per-service-call persistence and an approval-required tool,
/// a two-turn approval flow persists the correct final history:
/// Turn 1: user asks → model returns FCC → FICC converts to ToolApprovalRequestContent → returned to caller.
/// Turn 2: caller sends ToolApprovalResponseContent → FICC processes approval, invokes function, calls model again.
/// Final history: [user, assistant(FCC), tool(FRC), assistant(final)].
/// </summary>
[Fact]
public async Task RunAsync_ApprovalRequired_PerServiceCallPersistence_PersistsCorrectHistoryAsync()
{
// Arrange
var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather");
var approvalTool = new ApprovalRequiredAIFunction(tool);
var callIndex = new ChatClientAgentTestHelper.Ref<int>(0);
var capturedInputs = new List<List<ChatMessage>>();
var serviceExpectations = new List<ChatClientAgentTestHelper.ServiceCallExpectation>
{
// Turn 1: model returns a function call (FICC will convert to approval request)
new(new ChatResponse([new(ChatRole.Assistant,
[new FunctionCallContent("call1", "GetWeather", new Dictionary<string, object?> { ["city"] = "Amsterdam" })])])),
// Turn 2: after approval, FICC invokes the function and calls the model again
new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])),
};
// Act — Turn 1: initial request
var result1 = await ChatClientAgentTestHelper.RunAsync(
inputMessages: [new(ChatRole.User, "What's the weather?")],
serviceCallExpectations: serviceExpectations,
agentOptions: new()
{
ChatOptions = new() { Tools = [approvalTool] },
PersistChatHistoryAtEndOfRun = false,
},
callIndex: callIndex,
capturedInputs: capturedInputs);
// Verify Turn 1 returns exactly one approval request
var approvalRequests = result1.Response.Messages
.SelectMany(m => m.Contents)
.OfType<ToolApprovalRequestContent>()
.ToList();
Assert.Single(approvalRequests);
Assert.Equal(1, result1.TotalServiceCalls);
// Verify service received user message on first call
Assert.Single(capturedInputs);
Assert.Contains(capturedInputs[0], m => m.Role == ChatRole.User && m.Text == "What's the weather?");
// Act — Turn 2: send approval response
var approvalResponseMessages = approvalRequests.ConvertAll(req =>
new ChatMessage(ChatRole.User, [req.CreateResponse(approved: true)]));
await ChatClientAgentTestHelper.RunAsync(
inputMessages: approvalResponseMessages,
serviceCallExpectations: serviceExpectations,
existingSession: result1.Session,
existingAgent: result1.Agent,
existingMock: result1.MockService,
callIndex: callIndex,
capturedInputs: capturedInputs,
expectedServiceCallCount: 2,
expectedHistory:
[
new(ChatRole.User, TextContains: "What's the weather?"),
new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]),
new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]),
new(ChatRole.Assistant, TextContains: "sunny and 22°C"),
]);
// Verify second service call received the full conversation (user + FCC + FRC)
Assert.Equal(2, capturedInputs.Count);
Assert.Contains(capturedInputs[1], m => m.Contents.OfType<FunctionCallContent>().Any());
Assert.Contains(capturedInputs[1], m => m.Contents.OfType<FunctionResultContent>().Any());
}
#endregion
#region End-of-Run Persistence Approval Tests
/// <summary>
/// Verifies that with end-of-run persistence and an approval-required tool,
/// a two-turn approval flow persists the correct final history.
/// </summary>
[Fact]
public async Task RunAsync_ApprovalRequired_EndOfRunPersistence_PersistsCorrectHistoryAsync()
{
// Arrange
var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather");
var approvalTool = new ApprovalRequiredAIFunction(tool);
var callIndex = new ChatClientAgentTestHelper.Ref<int>(0);
var capturedInputs = new List<List<ChatMessage>>();
var serviceExpectations = new List<ChatClientAgentTestHelper.ServiceCallExpectation>
{
new(new ChatResponse([new(ChatRole.Assistant,
[new FunctionCallContent("call1", "GetWeather", new Dictionary<string, object?> { ["city"] = "Amsterdam" })])])),
new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])),
};
// Act — Turn 1
var result1 = await ChatClientAgentTestHelper.RunAsync(
inputMessages: [new(ChatRole.User, "What's the weather?")],
serviceCallExpectations: serviceExpectations,
agentOptions: new()
{
ChatOptions = new() { Tools = [approvalTool] },
PersistChatHistoryAtEndOfRun = true,
},
callIndex: callIndex,
capturedInputs: capturedInputs);
var approvalRequests = result1.Response.Messages
.SelectMany(m => m.Contents)
.OfType<ToolApprovalRequestContent>()
.ToList();
Assert.Single(approvalRequests);
// Act — Turn 2
var approvalResponseMessages = approvalRequests.ConvertAll(req =>
new ChatMessage(ChatRole.User, [req.CreateResponse(approved: true)]));
var result2 = await ChatClientAgentTestHelper.RunAsync(
inputMessages: approvalResponseMessages,
serviceCallExpectations: serviceExpectations,
existingSession: result1.Session,
existingAgent: result1.Agent,
existingMock: result1.MockService,
callIndex: callIndex,
capturedInputs: capturedInputs,
expectedServiceCallCount: 2,
expectedHistory:
[
// End-of-run persistence retains the approval request from Turn 1
new(ChatRole.User, TextContains: "What's the weather?"),
new(ChatRole.Assistant, ContentTypes: [typeof(ToolApprovalRequestContent)]),
new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]),
new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]),
new(ChatRole.Assistant, TextContains: "sunny and 22°C"),
]);
}
#endregion
#region Service-Stored History Approval Tests
/// <summary>
/// Verifies that with service-stored history (ConversationId returned) and an approval-required tool,
/// the two-turn approval flow completes without errors and the session gets the ConversationId.
/// </summary>
[Fact]
public async Task RunAsync_ApprovalRequired_ServiceStoredHistory_CompletesWithoutErrorAsync()
{
// Arrange
const string ConversationId = "thread-456";
var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather");
var approvalTool = new ApprovalRequiredAIFunction(tool);
var callIndex = new ChatClientAgentTestHelper.Ref<int>(0);
var capturedInputs = new List<List<ChatMessage>>();
var serviceExpectations = new List<ChatClientAgentTestHelper.ServiceCallExpectation>
{
new(new ChatResponse([new(ChatRole.Assistant,
[new FunctionCallContent("call1", "GetWeather", new Dictionary<string, object?> { ["city"] = "Amsterdam" })])])
{
ConversationId = ConversationId,
}),
new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])
{
ConversationId = ConversationId,
}),
};
// Act — Turn 1
var result1 = await ChatClientAgentTestHelper.RunAsync(
inputMessages: [new(ChatRole.User, "What's the weather?")],
serviceCallExpectations: serviceExpectations,
agentOptions: new()
{
ChatOptions = new() { Tools = [approvalTool] },
PersistChatHistoryAtEndOfRun = false,
},
callIndex: callIndex,
capturedInputs: capturedInputs);
var approvalRequests = result1.Response.Messages
.SelectMany(m => m.Contents)
.OfType<ToolApprovalRequestContent>()
.ToList();
Assert.Single(approvalRequests);
Assert.Equal(ConversationId, result1.Session.ConversationId);
// Act — Turn 2
var approvalResponseMessages = approvalRequests.ConvertAll(req =>
new ChatMessage(ChatRole.User, [req.CreateResponse(approved: true)]));
var result2 = await ChatClientAgentTestHelper.RunAsync(
inputMessages: approvalResponseMessages,
serviceCallExpectations: serviceExpectations,
existingSession: result1.Session,
existingAgent: result1.Agent,
existingMock: result1.MockService,
callIndex: callIndex,
capturedInputs: capturedInputs,
expectedServiceCallCount: 2);
// Assert — session should retain the ConversationId, response should be correct
Assert.Equal(ConversationId, result2.Session.ConversationId);
Assert.Contains(result2.Response.Messages, m => m.Text == "The weather in Amsterdam is sunny and 22°C.");
}
#endregion
#region Approval Rejected Tests
/// <summary>
/// Verifies that when an approval is rejected, the rejection result is persisted in the history
/// and the model receives the rejection information.
/// </summary>
[Fact]
public async Task RunAsync_ApprovalRejected_PersistsRejectionInHistoryAsync()
{
// Arrange
var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather");
var approvalTool = new ApprovalRequiredAIFunction(tool);
var callIndex = new ChatClientAgentTestHelper.Ref<int>(0);
var capturedInputs = new List<List<ChatMessage>>();
var serviceExpectations = new List<ChatClientAgentTestHelper.ServiceCallExpectation>
{
// Turn 1: model requests function call
new(new ChatResponse([new(ChatRole.Assistant,
[new FunctionCallContent("call1", "GetWeather", new Dictionary<string, object?> { ["city"] = "Amsterdam" })])])),
// Turn 2: after rejection, model gets the rejection info and responds accordingly
new(new ChatResponse([new(ChatRole.Assistant, "I'm sorry, I cannot check the weather without your approval.")])),
};
// Act — Turn 1
var result1 = await ChatClientAgentTestHelper.RunAsync(
inputMessages: [new(ChatRole.User, "What's the weather?")],
serviceCallExpectations: serviceExpectations,
agentOptions: new()
{
ChatOptions = new() { Tools = [approvalTool] },
PersistChatHistoryAtEndOfRun = false,
},
callIndex: callIndex,
capturedInputs: capturedInputs);
var approvalRequests = result1.Response.Messages
.SelectMany(m => m.Contents)
.OfType<ToolApprovalRequestContent>()
.ToList();
Assert.Single(approvalRequests);
// Act — Turn 2: reject the approval
var rejectionMessages = approvalRequests.ConvertAll(req =>
new ChatMessage(ChatRole.User, [req.CreateResponse(approved: false, reason: "User declined")]));
var result2 = await ChatClientAgentTestHelper.RunAsync(
inputMessages: rejectionMessages,
serviceCallExpectations: serviceExpectations,
existingSession: result1.Session,
existingAgent: result1.Agent,
existingMock: result1.MockService,
callIndex: callIndex,
capturedInputs: capturedInputs,
expectedServiceCallCount: 2);
// Assert — history should contain the rejection result (FRC with rejection)
var history = ChatClientAgentTestHelper.GetPersistedHistory(result2.Agent, result2.Session);
Assert.True(
history.Count >= 3,
$"Expected at least 3 messages in history, got {history.Count}.\n{ChatClientAgentTestHelper.FormatMessages(history)}");
Assert.Contains(history, m => m.Role == ChatRole.User && m.Text == "What's the weather?");
Assert.Contains(history, m => m.Contents.OfType<FunctionResultContent>().Any(
frc => frc.Result?.ToString()?.Contains("rejected") == true));
Assert.Contains(history, m => m.Role == ChatRole.Assistant &&
m.Text == "I'm sorry, I cannot check the weather without your approval.");
// Verify the second service call received the rejection FRC
Assert.Equal(2, capturedInputs.Count);
Assert.Contains(capturedInputs[1], m => m.Contents.OfType<FunctionResultContent>().Any(
frc => frc.Result?.ToString()?.Contains("rejected") == true));
}
#endregion
}
@@ -500,4 +500,158 @@ public class ChatClientAgent_ChatHistoryManagementTests
}
#endregion
#region End-to-End Chat History Persistence Tests
/// <summary>
/// Verifies that with per-service-call persistence (default), a simple request/response
/// results in the correct chat history being persisted: [user, assistant].
/// </summary>
[Fact]
public async Task RunAsync_PerServiceCallPersistence_SimpleResponse_PersistsCorrectHistoryAsync()
{
// Arrange & Act & Assert
await ChatClientAgentTestHelper.RunAsync(
inputMessages: [new(ChatRole.User, "Hello")],
serviceCallExpectations:
[
new(new ChatResponse([new(ChatRole.Assistant, "Hi there")])),
],
agentOptions: new()
{
ChatOptions = new() { Instructions = "Be helpful" },
PersistChatHistoryAtEndOfRun = false,
},
expectedServiceCallCount: 1,
expectedHistory:
[
new(ChatRole.User, TextContains: "Hello"),
new(ChatRole.Assistant, TextContains: "Hi there"),
]);
}
/// <summary>
/// Verifies that with per-service-call persistence and a function calling loop,
/// the full conversation is persisted: [user, assistant(FCC), tool(FRC), assistant(final)].
/// </summary>
[Fact]
public async Task RunAsync_PerServiceCallPersistence_FunctionCallingLoop_PersistsCorrectHistoryAsync()
{
// Arrange
var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather");
// Act & Assert
await ChatClientAgentTestHelper.RunAsync(
inputMessages: [new(ChatRole.User, "What's the weather?")],
serviceCallExpectations:
[
// First call: model requests a function call
new(new ChatResponse([new(ChatRole.Assistant,
[new FunctionCallContent("call1", "GetWeather", new Dictionary<string, object?> { ["city"] = "Amsterdam" })])])),
// Second call: model returns final response after seeing function result
new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])),
],
agentOptions: new()
{
ChatOptions = new() { Tools = [tool] },
PersistChatHistoryAtEndOfRun = false,
},
expectedServiceCallCount: 2,
expectedHistory:
[
new(ChatRole.User, TextContains: "What's the weather?"),
new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]),
new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]),
new(ChatRole.Assistant, TextContains: "sunny and 22°C"),
]);
}
/// <summary>
/// Verifies that with end-of-run persistence, a simple request/response
/// results in the correct chat history being persisted: [user, assistant].
/// </summary>
[Fact]
public async Task RunAsync_EndOfRunPersistence_SimpleResponse_PersistsCorrectHistoryAsync()
{
// Arrange & Act & Assert
await ChatClientAgentTestHelper.RunAsync(
inputMessages: [new(ChatRole.User, "Hello")],
serviceCallExpectations:
[
new(new ChatResponse([new(ChatRole.Assistant, "Hi there")])),
],
agentOptions: new()
{
ChatOptions = new() { Instructions = "Be helpful" },
PersistChatHistoryAtEndOfRun = true,
},
expectedServiceCallCount: 1,
expectedHistory:
[
new(ChatRole.User, TextContains: "Hello"),
new(ChatRole.Assistant, TextContains: "Hi there"),
]);
}
/// <summary>
/// Verifies that with end-of-run persistence and a function calling loop,
/// the full conversation is persisted: [user, assistant(FCC), tool(FRC), assistant(final)].
/// </summary>
[Fact]
public async Task RunAsync_EndOfRunPersistence_FunctionCallingLoop_PersistsCorrectHistoryAsync()
{
// Arrange
var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather");
// Act & Assert
await ChatClientAgentTestHelper.RunAsync(
inputMessages: [new(ChatRole.User, "What's the weather?")],
serviceCallExpectations:
[
new(new ChatResponse([new(ChatRole.Assistant,
[new FunctionCallContent("call1", "GetWeather", new Dictionary<string, object?> { ["city"] = "Amsterdam" })])])),
new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])),
],
agentOptions: new()
{
ChatOptions = new() { Tools = [tool] },
PersistChatHistoryAtEndOfRun = true,
},
expectedServiceCallCount: 2,
expectedHistory:
[
new(ChatRole.User, TextContains: "What's the weather?"),
new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]),
new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]),
new(ChatRole.Assistant, TextContains: "sunny and 22°C"),
]);
}
/// <summary>
/// Verifies that when the service returns a ConversationId (service-stored history),
/// the session gets the ConversationId and no errors occur during the run.
/// </summary>
[Fact]
public async Task RunAsync_ServiceStoredHistory_SetsConversationIdAndCompletesWithoutErrorAsync()
{
// Arrange & Act
var result = await ChatClientAgentTestHelper.RunAsync(
inputMessages: [new(ChatRole.User, "Hello")],
serviceCallExpectations:
[
new(new ChatResponse([new(ChatRole.Assistant, "Hi there")]) { ConversationId = "thread-123" }),
],
agentOptions: new()
{
ChatOptions = new() { Instructions = "Be helpful" },
PersistChatHistoryAtEndOfRun = false,
},
expectedServiceCallCount: 1);
// Assert — session should have the conversation id from the service
Assert.Equal("thread-123", result.Session.ConversationId);
Assert.Contains(result.Response.Messages, m => m.Text == "Hi there");
}
#endregion
}
@@ -176,10 +176,12 @@ public class ChatClientAgent_ChatOptionsMergingTests
}
/// <summary>
/// Verify that ChatOptions merging returns null when both agent and request have no ChatOptions.
/// Verify that ChatOptions merging returns a non-null ChatOptions instance with null ConversationId
/// when both agent and request have no ChatOptions. The sentinel conversation ID is set for
/// per-service-call persistence and stripped before reaching the inner client.
/// </summary>
[Fact]
public async Task ChatOptionsMergingReturnsNullWhenBothAgentAndRequestHaveNoneAsync()
public async Task ChatOptionsMergingReturnsChatOptionsWithNullConversationIdWhenBothAgentAndRequestHaveNoneAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
@@ -189,7 +191,7 @@ public class ChatClientAgent_ChatOptionsMergingTests
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((msgs, opts, ct) =>
capturedChatOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
@@ -199,8 +201,9 @@ public class ChatClientAgent_ChatOptionsMergingTests
// Act
await agent.RunAsync(messages);
// Assert
Assert.Null(capturedChatOptions);
// Assert — ChatOptions is non-null because the sentinel was set, but ConversationId is null (stripped)
Assert.NotNull(capturedChatOptions);
Assert.Null(capturedChatOptions!.ConversationId);
}
/// <summary>
@@ -763,4 +763,171 @@ public class ChatHistoryPersistingChatClientTests
await Task.CompletedTask;
}
/// <summary>
/// Verifies that when per-service-call persistence is active and no real conversation ID exists,
/// <see cref="ChatClientAgent"/> sets the <see cref="ChatHistoryPersistingChatClient.LocalHistoryConversationId"/>
/// sentinel on the chat options and <see cref="ChatHistoryPersistingChatClient"/> strips it before
/// forwarding to the inner client.
/// </summary>
[Fact]
public async Task RunAsync_SetsAndStripsSentinelConversationId_WhenPerServiceCallPersistenceActiveAsync()
{
// Arrange
ChatOptions? capturedOptions = null;
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test" },
PersistChatHistoryAtEndOfRun = false,
});
// Act
await agent.RunAsync([new(ChatRole.User, "test")]);
// Assert — the inner client should NOT see the sentinel conversation ID
Assert.NotNull(capturedOptions);
Assert.Null(capturedOptions!.ConversationId);
}
/// <summary>
/// Verifies that the sentinel is NOT set when end-of-run persistence is enabled
/// (mark-only mode), since the issue only applies to per-service-call persistence.
/// </summary>
[Fact]
public async Task RunAsync_DoesNotSetSentinel_WhenEndOfRunPersistenceEnabledAsync()
{
// Arrange
ChatOptions? capturedOptions = null;
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test" },
PersistChatHistoryAtEndOfRun = true,
});
// Act
await agent.RunAsync([new(ChatRole.User, "test")]);
// Assert — the inner client should see options but NOT the sentinel conversation ID
Assert.NotNull(capturedOptions);
Assert.Null(capturedOptions!.ConversationId);
}
/// <summary>
/// Verifies that the sentinel is NOT set when a real conversation ID is already present
/// on the session (indicating server-side history management).
/// </summary>
[Fact]
public async Task RunAsync_DoesNotSetSentinel_WhenRealConversationIdExistsAsync()
{
// Arrange
const string RealConversationId = "real-conv-123";
ChatOptions? capturedOptions = null;
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])
{
ConversationId = RealConversationId,
});
ChatClientAgent agent = new(mockService.Object, options: new()
{
PersistChatHistoryAtEndOfRun = false,
});
// Create a session with a real conversation ID.
var session = await agent.CreateSessionAsync(RealConversationId);
// Act
await agent.RunAsync([new(ChatRole.User, "test")], session);
// Assert — the inner client should see the real conversation ID, not the sentinel
Assert.NotNull(capturedOptions);
Assert.Equal(RealConversationId, capturedOptions!.ConversationId);
}
/// <summary>
/// Verifies that the sentinel is set and stripped correctly in the streaming path.
/// </summary>
[Fact]
public async Task RunStreamingAsync_SetsAndStripsSentinelConversationId_WhenPerServiceCallPersistenceActiveAsync()
{
// Arrange
ChatOptions? capturedOptions = null;
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
.Returns(CreateAsyncEnumerableAsync(new ChatResponseUpdate(role: ChatRole.Assistant, content: "response")));
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test" },
PersistChatHistoryAtEndOfRun = false,
});
// Act
await foreach (var _ in agent.RunStreamingAsync([new(ChatRole.User, "test")]))
{
// Consume the stream.
}
// Assert — the inner client should NOT see the sentinel conversation ID
Assert.NotNull(capturedOptions);
Assert.Null(capturedOptions!.ConversationId);
}
/// <summary>
/// Verifies that the session's conversation ID is NOT set to the sentinel after the run.
/// The sentinel should only exist transiently on the ChatOptions for the pipeline.
/// </summary>
[Fact]
public async Task RunAsync_SentinelDoesNotLeakToSession_WhenPerServiceCallPersistenceActiveAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new()
{
PersistChatHistoryAtEndOfRun = false,
});
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await agent.RunAsync([new(ChatRole.User, "test")], session);
// Assert — session should NOT have the sentinel conversation ID
Assert.Null(session!.ConversationId);
}
}