// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using Moq.Protected;
namespace Microsoft.Agents.AI.UnitTests;
///
/// Contains unit tests for the decorator,
/// verifying that it persists messages via the after each
/// individual service call by default, or marks messages for end-of-run persistence when the
/// option is enabled.
///
public class PerServiceCallChatHistoryPersistingChatClientTests
{
///
/// Verifies that by default (RequirePerServiceCallChatHistoryPersistence is false),
/// the ChatHistoryProvider receives messages after a successful non-streaming call.
///
[Fact]
public async Task RunAsync_PersistsMessagesPerServiceCall_ByDefaultAsync()
{
// Arrange
Mock mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny>(),
It.IsAny(),
It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
Mock mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask>(ctx.RequestMessages.ToList()));
mockChatHistoryProvider
.Protected()
.Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns(new ValueTask());
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await agent.RunAsync([new(ChatRole.User, "test")], session);
// Assert — InvokedCoreAsync should be called by the decorator (per service call)
mockChatHistoryProvider
.Protected()
.Verify("InvokedCoreAsync", Times.Once(),
ItExpr.Is(x =>
x.RequestMessages.Any(m => m.Text == "test") &&
x.ResponseMessages!.Any(m => m.Text == "response")),
ItExpr.IsAny());
}
///
/// Verifies that when per-service-call persistence is active (default),
/// the ChatHistoryProvider receives messages at the end of the run.
///
[Fact]
public async Task RunAsync_PersistsMessagesAtEndOfRun_WhenOptionEnabledAsync()
{
// Arrange
Mock mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny>(),
It.IsAny(),
It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
Mock mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask>(ctx.RequestMessages.ToList()));
mockChatHistoryProvider
.Protected()
.Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns(new ValueTask());
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await agent.RunAsync([new(ChatRole.User, "test")], session);
// Assert — InvokedCoreAsync should be called once by the agent (end of run)
mockChatHistoryProvider
.Protected()
.Verify("InvokedCoreAsync", Times.Once(),
ItExpr.Is(x =>
x.RequestMessages.Any(m => m.Text == "test") &&
x.ResponseMessages!.Any(m => m.Text == "response")),
ItExpr.IsAny());
}
///
/// Verifies that when per-service-call persistence is active (default) and the service call fails,
/// the ChatHistoryProvider is notified with the exception.
///
[Fact]
public async Task RunAsync_NotifiesProviderOfFailure_WhenPerServiceCallPersistenceActiveAsync()
{
// Arrange
var expectedException = new InvalidOperationException("Service failed");
Mock mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny>(),
It.IsAny(),
It.IsAny())).ThrowsAsync(expectedException);
Mock mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask>(ctx.RequestMessages.ToList()));
mockChatHistoryProvider
.Protected()
.Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns(new ValueTask());
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await Assert.ThrowsAsync(() => agent.RunAsync([new(ChatRole.User, "test")], session));
// Assert — the decorator should have notified the provider of the failure
mockChatHistoryProvider
.Protected()
.Verify("InvokedCoreAsync", Times.Once(),
ItExpr.Is(x =>
x.InvokeException != null &&
x.InvokeException.Message == "Service failed"),
ItExpr.IsAny());
}
///
/// Verifies that the decorator is NOT injected by default (RequirePerServiceCallChatHistoryPersistence is false).
///
[Fact]
public void ChatClient_DoesNotContainDecorator_ByDefault()
{
// Arrange
Mock mockService = new();
// Act
ChatClientAgent agent = new(mockService.Object, options: new());
// Assert
var decorator = agent.ChatClient.GetService();
Assert.Null(decorator);
}
///
/// Verifies that the decorator is injected when RequirePerServiceCallChatHistoryPersistence is true.
///
[Fact]
public void ChatClient_ContainsDecorator_WhenRequirePerServiceCallChatHistoryPersistence()
{
// Arrange
Mock mockService = new();
// Act
ChatClientAgent agent = new(mockService.Object, options: new()
{
RequirePerServiceCallChatHistoryPersistence = true,
});
// Assert
var decorator = agent.ChatClient.GetService();
Assert.NotNull(decorator);
}
///
/// Verifies that the decorator is NOT injected when UseProvidedChatClientAsIs is true.
///
[Fact]
public void ChatClient_DoesNotContainDecorator_WhenUseProvidedChatClientAsIs()
{
// Arrange
Mock mockService = new();
// Act
ChatClientAgent agent = new(mockService.Object, options: new()
{
UseProvidedChatClientAsIs = true,
});
// Assert
var decorator = agent.ChatClient.GetService();
Assert.Null(decorator);
}
///
/// Verifies that the RequirePerServiceCallChatHistoryPersistence option is included in Clone().
///
[Fact]
public void ChatClientAgentOptions_Clone_IncludesRequirePerServiceCallChatHistoryPersistence()
{
// Arrange
var options = new ChatClientAgentOptions
{
RequirePerServiceCallChatHistoryPersistence = true,
};
// Act
var cloned = options.Clone();
// Assert
Assert.True(cloned.RequirePerServiceCallChatHistoryPersistence);
}
///
/// Verifies that when per-service-call persistence is active (default) and the service call
/// involves a function invocation loop, the ChatHistoryProvider is called after each individual
/// service call (not just once at the end).
///
[Fact]
public async Task RunAsync_PersistsPerServiceCall_DuringFunctionInvocationLoopAsync()
{
// Arrange
int serviceCallCount = 0;
Mock mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny>(),
It.IsAny(),
It.IsAny()))
.Returns(() =>
{
serviceCallCount++;
if (serviceCallCount == 1)
{
// First call returns a tool call
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, [new FunctionCallContent("call1", "myTool", new Dictionary())])]));
}
// Second call returns a final response
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final response")]));
});
var invokedContexts = new List();
Mock mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask>(ctx.RequestMessages.ToList()));
mockChatHistoryProvider
.Protected()
.Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Callback((ChatHistoryProvider.InvokedContext ctx, CancellationToken _) => invokedContexts.Add(ctx))
.Returns(() => new ValueTask());
// Define a simple tool
var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool");
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Tools = [tool] },
ChatHistoryProvider = mockChatHistoryProvider.Object,
RequirePerServiceCallChatHistoryPersistence = true,
}, services: new ServiceCollection().BuildServiceProvider());
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
Exception? caughtException = null;
try
{
await agent.RunAsync([new(ChatRole.User, "test")], session);
}
catch (Exception ex)
{
caughtException = ex;
}
// Diagnostic: check if there was an unexpected exception
Assert.Null(caughtException);
// Assert — the decorator should have been called twice (once per service call in the function invocation loop)
Assert.Equal(2, serviceCallCount);
Assert.Equal(2, invokedContexts.Count);
// First invocation should have the user message as request and tool call response
Assert.NotNull(invokedContexts[0].ResponseMessages);
var firstRequestMessages = invokedContexts[0].RequestMessages.ToList();
Assert.Contains(firstRequestMessages, m => m.Text == "test");
Assert.Contains(invokedContexts[0].ResponseMessages!, m => m.Contents.OfType().Any());
// Second invocation: request messages should NOT include the original user message (already notified).
// It should only include messages added since the first call (assistant tool call + tool result).
Assert.NotNull(invokedContexts[1].ResponseMessages);
var secondRequestMessages = invokedContexts[1].RequestMessages.ToList();
Assert.DoesNotContain(secondRequestMessages, m => m.Text == "test");
Assert.Contains(invokedContexts[1].ResponseMessages!, m => m.Text == "final response");
}
///
/// Verifies that when per-service-call persistence is active (default) with streaming,
/// the ChatHistoryProvider receives messages after the stream completes.
///
[Fact]
public async Task RunStreamingAsync_PersistsMessagesPerServiceCall_ByDefaultAsync()
{
// Arrange
Mock mockService = new();
mockService.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny>(),
It.IsAny(),
It.IsAny()))
.Returns(CreateAsyncEnumerableAsync(
new ChatResponseUpdate(ChatRole.Assistant, "streaming "),
new ChatResponseUpdate(ChatRole.Assistant, "response")));
Mock mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask>(ctx.RequestMessages.ToList()));
mockChatHistoryProvider
.Protected()
.Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns(new ValueTask());
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await foreach (var _ in agent.RunStreamingAsync([new(ChatRole.User, "test")], session))
{
// Consume stream
}
// Assert — InvokedCoreAsync should be called by the decorator
mockChatHistoryProvider
.Protected()
.Verify("InvokedCoreAsync", Times.Once(),
ItExpr.Is(x =>
x.RequestMessages.Any(m => m.Text == "test") &&
x.ResponseMessages != null),
ItExpr.IsAny());
}
///
/// Verifies that when per-service-call persistence is active (default),
/// AIContextProviders are also notified of new messages after a successful call.
///
[Fact]
public async Task RunAsync_NotifiesAIContextProviders_ByDefaultAsync()
{
// Arrange
Mock mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny>(),
It.IsAny(),
It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
Mock mockContextProvider = new(null, null, null);
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestAIContextProvider"]);
mockContextProvider
.Protected()
.Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns(() => new ValueTask(new AIContext()));
mockContextProvider
.Protected()
.Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns(() => new ValueTask());
ChatClientAgent agent = new(mockService.Object, options: new()
{
AIContextProviders = [mockContextProvider.Object],
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await agent.RunAsync([new(ChatRole.User, "test")], session);
// Assert — InvokedCoreAsync should be called by the decorator for the AIContextProvider
mockContextProvider
.Protected()
.Verify("InvokedCoreAsync", Times.Once(),
ItExpr.Is(x =>
x.ResponseMessages != null &&
x.ResponseMessages.Any(m => m.Text == "response")),
ItExpr.IsAny());
}
///
/// Verifies that when per-service-call persistence is active (default) and the service fails,
/// AIContextProviders are notified of the failure.
///
[Fact]
public async Task RunAsync_NotifiesAIContextProvidersOfFailure_ByDefaultAsync()
{
// Arrange
var expectedException = new InvalidOperationException("Service failed");
Mock mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny>(),
It.IsAny(),
It.IsAny())).ThrowsAsync(expectedException);
Mock mockContextProvider = new(null, null, null);
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestAIContextProvider"]);
mockContextProvider
.Protected()
.Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns(() => new ValueTask(new AIContext()));
mockContextProvider
.Protected()
.Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns(() => new ValueTask());
ChatClientAgent agent = new(mockService.Object, options: new()
{
AIContextProviders = [mockContextProvider.Object],
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await Assert.ThrowsAsync(() => agent.RunAsync([new(ChatRole.User, "test")], session));
// Assert — the decorator should have notified the AIContextProvider of the failure
mockContextProvider
.Protected()
.Verify("InvokedCoreAsync", Times.Once(),
ItExpr.Is(x =>
x.InvokeException != null &&
x.InvokeException.Message == "Service failed"),
ItExpr.IsAny());
}
///
/// Verifies that when per-service-call persistence is active (default),
/// both ChatHistoryProvider and AIContextProviders are notified together.
///
[Fact]
public async Task RunAsync_NotifiesBothProviders_ByDefaultAsync()
{
// Arrange
Mock mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny>(),
It.IsAny(),
It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
Mock mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask>(ctx.RequestMessages.ToList()));
mockChatHistoryProvider
.Protected()
.Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns(() => new ValueTask());
Mock mockContextProvider = new(null, null, null);
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestAIContextProvider"]);
mockContextProvider
.Protected()
.Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns(() => new ValueTask(new AIContext()));
mockContextProvider
.Protected()
.Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns(() => new ValueTask());
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
AIContextProviders = [mockContextProvider.Object],
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await agent.RunAsync([new(ChatRole.User, "test")], session);
// Assert — both providers should have been notified
mockChatHistoryProvider
.Protected()
.Verify("InvokedCoreAsync", Times.Once(),
ItExpr.Is(x =>
x.ResponseMessages != null &&
x.ResponseMessages.Any(m => m.Text == "response")),
ItExpr.IsAny());
mockContextProvider
.Protected()
.Verify("InvokedCoreAsync", Times.Once(),
ItExpr.Is(x =>
x.ResponseMessages != null &&
x.ResponseMessages.Any(m => m.Text == "response")),
ItExpr.IsAny());
}
///
/// Verifies that during a FIC loop, response messages from the first call are not
/// re-notified as request messages on the second call.
///
[Fact]
public async Task RunAsync_DoesNotReNotifyResponseMessagesAsRequestMessages_DuringFicLoopAsync()
{
// Arrange
int serviceCallCount = 0;
var assistantToolCallMessage = new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "myTool", new Dictionary())]);
Mock mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny>(),
It.IsAny(),
It.IsAny()))
.Returns(() =>
{
serviceCallCount++;
if (serviceCallCount == 1)
{
return Task.FromResult(new ChatResponse([assistantToolCallMessage]));
}
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final response")]));
});
var invokedContexts = new List();
Mock mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask>(ctx.RequestMessages.ToList()));
mockChatHistoryProvider
.Protected()
.Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Callback((ChatHistoryProvider.InvokedContext ctx, CancellationToken _) => invokedContexts.Add(ctx))
.Returns(() => new ValueTask());
var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool");
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Tools = [tool] },
ChatHistoryProvider = mockChatHistoryProvider.Object,
RequirePerServiceCallChatHistoryPersistence = true,
}, services: new ServiceCollection().BuildServiceProvider());
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await agent.RunAsync([new(ChatRole.User, "test")], session);
// Assert
Assert.Equal(2, invokedContexts.Count);
// The assistant tool call message was a response in call 1
Assert.Contains(invokedContexts[0].ResponseMessages!, m => ReferenceEquals(m, assistantToolCallMessage));
// It should NOT appear as a request in call 2 (it was already notified as a response)
var secondRequestMessages = invokedContexts[1].RequestMessages.ToList();
Assert.DoesNotContain(secondRequestMessages, m => ReferenceEquals(m, assistantToolCallMessage));
}
///
/// Verifies that when a failure occurs on the second call in a FIC loop,
/// only new request messages (not previously notified) are sent in the failure notification.
///
[Fact]
public async Task RunAsync_DeduplicatesRequestMessages_OnFailureDuringFicLoopAsync()
{
// Arrange
int serviceCallCount = 0;
Mock mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny>(),
It.IsAny(),
It.IsAny()))
.Returns(() =>
{
serviceCallCount++;
if (serviceCallCount == 1)
{
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, [new FunctionCallContent("call1", "myTool", new Dictionary())])]));
}
throw new InvalidOperationException("Service failure on second call");
});
var invokedContexts = new List();
Mock mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask>(ctx.RequestMessages.ToList()));
mockChatHistoryProvider
.Protected()
.Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Callback((ChatHistoryProvider.InvokedContext ctx, CancellationToken _) => invokedContexts.Add(ctx))
.Returns(() => new ValueTask());
var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool");
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Tools = [tool] },
ChatHistoryProvider = mockChatHistoryProvider.Object,
RequirePerServiceCallChatHistoryPersistence = true,
}, services: new ServiceCollection().BuildServiceProvider());
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await Assert.ThrowsAsync(() =>
agent.RunAsync([new(ChatRole.User, "test")], session));
// Assert — should have 2 notifications: success on call 1, failure on call 2
Assert.Equal(2, invokedContexts.Count);
// First notification: success, has user message as request
Assert.Null(invokedContexts[0].InvokeException);
Assert.Contains(invokedContexts[0].RequestMessages, m => m.Text == "test");
// Second notification: failure, should NOT include the user message (already notified)
Assert.NotNull(invokedContexts[1].InvokeException);
var failureRequestMessages = invokedContexts[1].RequestMessages.ToList();
Assert.DoesNotContain(failureRequestMessages, m => m.Text == "test");
}
///
/// Verifies that after a successful run with per-service-call persistence, the notified
/// messages are stamped with the persisted marker so they are not re-notified.
///
///
/// Verifies that when the inner client returns a real conversation ID,
/// the session's ConversationId is updated after the run.
///
[Fact]
public async Task RunAsync_UpdatesSessionConversationId_WhenServiceReturnsOneAsync()
{
// Arrange
const string ExpectedConversationId = "conv-123";
Mock mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny>(),
It.IsAny(),
It.IsAny()))
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])
{
ConversationId = ExpectedConversationId,
});
ChatClientAgent agent = new(mockService.Object);
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await agent.RunAsync([new(ChatRole.User, "test")], session);
// Assert — session should have the conversation ID returned by the inner client
Assert.Equal(ExpectedConversationId, session!.ConversationId);
}
private static async IAsyncEnumerable CreateAsyncEnumerableAsync(params ChatResponseUpdate[] updates)
{
foreach (var update in updates)
{
yield return update;
}
await Task.CompletedTask;
}
///
/// Verifies that when per-service-call persistence is active and no real conversation ID exists,
/// sets the
/// sentinel on the chat options and strips it before
/// forwarding to the inner client.
///
[Fact]
public async Task RunAsync_SetsAndStripsSentinelConversationId_WhenPerServiceCallPersistenceActiveAsync()
{
// Arrange
ChatOptions? capturedOptions = null;
Mock mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny>(),
It.IsAny(),
It.IsAny()))
.Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test" },
RequirePerServiceCallChatHistoryPersistence = true,
});
// 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);
}
///
/// 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.
///
[Fact]
public async Task RunAsync_DoesNotSetSentinel_WhenEndOfRunPersistenceEnabledAsync()
{
// Arrange
ChatOptions? capturedOptions = null;
Mock mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny>(),
It.IsAny(),
It.IsAny()))
.Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test" },
RequirePerServiceCallChatHistoryPersistence = 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);
}
///
/// Verifies that the sentinel is NOT set when a real conversation ID is already present
/// on the session (indicating server-side history management).
///
[Fact]
public async Task RunAsync_DoesNotSetSentinel_WhenRealConversationIdExistsAsync()
{
// Arrange
const string RealConversationId = "real-conv-123";
ChatOptions? capturedOptions = null;
Mock mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny>(),
It.IsAny(),
It.IsAny()))
.Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])
{
ConversationId = RealConversationId,
});
ChatClientAgent agent = new(mockService.Object, options: new()
{
RequirePerServiceCallChatHistoryPersistence = true,
});
// 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);
}
///
/// Verifies that the sentinel is set and stripped correctly in the streaming path.
///
[Fact]
public async Task RunStreamingAsync_SetsAndStripsSentinelConversationId_WhenPerServiceCallPersistenceActiveAsync()
{
// Arrange
ChatOptions? capturedOptions = null;
Mock mockService = new();
mockService.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny>(),
It.IsAny(),
It.IsAny()))
.Callback, 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" },
RequirePerServiceCallChatHistoryPersistence = true,
});
// 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);
}
///
/// Verifies that the session's conversation ID IS set to the sentinel after the run
/// when simulating service-stored chat history. This allows subsequent runs to
/// skip provider resolution in the agent (the decorator handles it).
///
[Fact]
public async Task RunAsync_SetsSentinelOnSession_WhenRequirePerServiceCallChatHistoryPersistenceActiveAsync()
{
// Arrange
Mock mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny>(),
It.IsAny(),
It.IsAny()))
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new()
{
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await agent.RunAsync([new(ChatRole.User, "test")], session);
// Assert — session should have the sentinel conversation ID
Assert.Equal(PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId, session!.ConversationId);
}
///
/// Verifies that when simulating service-stored chat history and the service returns
/// a real , the conflict detection in
/// throws because both a
/// and a service-managed ConversationId are present.
///
[Fact]
public async Task RunAsync_Throws_WhenServiceReturnsRealConversationIdWithChatHistoryProviderAsync()
{
// Arrange
const string RealConversationId = "service-conv-456";
Mock mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask>(ctx.RequestMessages.ToList()));
mockChatHistoryProvider
.Protected()
.Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns(new ValueTask());
Mock mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny>(),
It.IsAny(),
It.IsAny()))
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])
{
ConversationId = RealConversationId,
});
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act & Assert — conflict detection should throw
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await Assert.ThrowsAsync(() => agent.RunAsync([new(ChatRole.User, "test")], session));
}
///
/// Verifies that when simulating service-stored chat history and the request carries a real
/// , the decorator skips history loading but still
/// notifies s on success and updates the session ConversationId.
///
[Fact]
public async Task RunAsync_NotifiesProvidersAndUpdatesSession_WhenRequestHasRealConversationIdAsync()
{
// Arrange
const string RealConversationId = "real-conv-request";
const string ServiceConversationId = "real-conv-response";
Mock mockContextProvider = new(null, null, null);
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestContextProvider"]);
mockContextProvider
.Protected()
.Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask(new AIContext { Messages = ctx.AIContext.Messages }));
mockContextProvider
.Protected()
.Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns(new ValueTask());
Mock mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny>(),
It.IsAny(),
It.IsAny()))
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])
{
ConversationId = ServiceConversationId,
});
ChatClientAgent agent = new(mockService.Object, options: new()
{
RequirePerServiceCallChatHistoryPersistence = true,
AIContextProviders = [mockContextProvider.Object],
});
// Create a session with a real conversation ID so it's on chatOptions.
var session = await agent.CreateSessionAsync(RealConversationId);
// Act
await agent.RunAsync([new(ChatRole.User, "test")], session);
// Assert — AIContextProvider.InvokedAsync should have been called
mockContextProvider
.Protected()
.Verify("InvokedCoreAsync", Times.Once(),
ItExpr.Is(x =>
x.RequestMessages.Any(m => m.Text == "test") &&
x.ResponseMessages!.Any(m => m.Text == "response")),
ItExpr.IsAny());
// Assert — session should have the service-returned ConversationId
Assert.Equal(ServiceConversationId, (session as ChatClientAgentSession)!.ConversationId);
}
///
/// Verifies that when simulating service-stored chat history and the request carries a real
/// , the decorator notifies providers of failure
/// when the inner client throws.
///
[Fact]
public async Task RunAsync_NotifiesProvidersOfFailure_WhenRequestHasRealConversationIdAsync()
{
// Arrange
const string RealConversationId = "real-conv-failure";
Mock mockContextProvider = new(null, null, null);
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestContextProvider"]);
mockContextProvider
.Protected()
.Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask(new AIContext { Messages = ctx.AIContext.Messages }));
mockContextProvider
.Protected()
.Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns(new ValueTask());
Mock mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny>(),
It.IsAny(),
It.IsAny()))
.ThrowsAsync(new InvalidOperationException("Service error"));
ChatClientAgent agent = new(mockService.Object, options: new()
{
RequirePerServiceCallChatHistoryPersistence = true,
AIContextProviders = [mockContextProvider.Object],
});
var session = await agent.CreateSessionAsync(RealConversationId);
// Act & Assert — should throw
await Assert.ThrowsAsync(() => agent.RunAsync([new(ChatRole.User, "test")], session));
// Assert — AIContextProvider.InvokedAsync should have been called with the failure
mockContextProvider
.Protected()
.Verify("InvokedCoreAsync", Times.Once(),
ItExpr.Is(x => x.InvokeException != null),
ItExpr.IsAny());
}
///
/// Verifies that in the streaming path, when the request carries a real
/// , the decorator skips history loading but still
/// notifies providers and updates the session ConversationId.
///
[Fact]
public async Task RunStreamingAsync_NotifiesProvidersAndUpdatesSession_WhenRequestHasRealConversationIdAsync()
{
// Arrange
const string RealConversationId = "real-conv-streaming";
const string ServiceConversationId = "service-conv-streaming";
Mock mockContextProvider = new(null, null, null);
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestContextProvider"]);
mockContextProvider
.Protected()
.Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask(new AIContext { Messages = ctx.AIContext.Messages }));
mockContextProvider
.Protected()
.Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny())
.Returns(new ValueTask());
Mock mockService = new();
mockService.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny>(),
It.IsAny(),
It.IsAny()))
.Returns(CreateAsyncEnumerableAsync(
new ChatResponseUpdate(ChatRole.Assistant, "streamed") { ConversationId = ServiceConversationId }));
ChatClientAgent agent = new(mockService.Object, options: new()
{
RequirePerServiceCallChatHistoryPersistence = true,
AIContextProviders = [mockContextProvider.Object],
});
var session = await agent.CreateSessionAsync(RealConversationId);
// Act
await foreach (var _ in agent.RunStreamingAsync([new(ChatRole.User, "test")], session))
{
// Consume all updates.
}
// Assert — AIContextProvider.InvokedAsync should have been called
mockContextProvider
.Protected()
.Verify("InvokedCoreAsync", Times.Once(),
ItExpr.IsAny