mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
committed by
GitHub
Unverified
parent
63dee91a5f
commit
3585581c7a
@@ -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>
|
||||
|
||||
+306
@@ -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
|
||||
}
|
||||
+154
@@ -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
|
||||
}
|
||||
|
||||
+8
-5
@@ -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>
|
||||
|
||||
+167
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user