.NET: Allow Simulating service stored ChatHistory to improve consistency (#4974)

* Allow Simulating service stored ChatHistory to improve consistency

* Fixing bug in ServiceStoredSimulatingChatClient

* Addressing PR comments.

* Address PR comments

* Apply suggestion from @SergeyMenshykh

Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>

* Fix bug

---------

Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
This commit is contained in:
westey
2026-03-30 18:52:01 +00:00
committed by GitHub
co-authored by SergeyMenshykh
parent 18f7ba8632
commit 401e5dc7e8
16 changed files with 950 additions and 714 deletions
@@ -14,7 +14,7 @@ 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
/// end-to-end behavior with <see cref="ServiceStoredSimulatingChatClient"/> and
/// <see cref="FunctionInvokingChatClient"/>.
/// </summary>
internal static class ChatClientAgentTestHelper
@@ -379,12 +379,10 @@ public partial class ChatClientAgentTests
}
/// <summary>
/// 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.
/// Verify that RunAsync passes null ChatOptions when using regular AgentRunOptions.
/// </summary>
[Fact]
public async Task RunAsyncPassesChatOptionsWithNullConversationIdWhenUsingRegularAgentRunOptionsAsync()
public async Task RunAsyncPassesNullChatOptionsWhenUsingRegularAgentRunOptionsAsync()
{
// Arrange
ChatOptions? capturedOptions = null;
@@ -403,9 +401,8 @@ public partial class ChatClientAgentTests
// Act
await agent.RunAsync([new(ChatRole.User, "test")], options: runOptions);
// Assert — the inner client receives ChatOptions with null ConversationId (sentinel was stripped)
Assert.NotNull(capturedOptions);
Assert.Null(capturedOptions!.ConversationId);
// Assert
Assert.Null(capturedOptions);
}
/// <summary>
@@ -9,7 +9,7 @@ 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"/>,
/// <see cref="ChatClientAgent"/> class with <see cref="ServiceStoredSimulatingChatClient"/>,
/// ensuring that chat history is correctly persisted across multi-turn approval interactions.
/// </summary>
public class ChatClientAgent_ApprovalsTests
@@ -48,7 +48,7 @@ public class ChatClientAgent_ApprovalsTests
agentOptions: new()
{
ChatOptions = new() { Tools = [approvalTool] },
PersistChatHistoryAtEndOfRun = false,
SimulateServiceStoredChatHistory = true,
},
callIndex: callIndex,
capturedInputs: capturedInputs);
@@ -123,7 +123,6 @@ public class ChatClientAgent_ApprovalsTests
agentOptions: new()
{
ChatOptions = new() { Tools = [approvalTool] },
PersistChatHistoryAtEndOfRun = true,
},
callIndex: callIndex,
capturedInputs: capturedInputs);
@@ -150,8 +149,10 @@ public class ChatClientAgent_ApprovalsTests
expectedHistory:
[
// End-of-run persistence retains the approval request from Turn 1
// and the approval response from Turn 2
new(ChatRole.User, TextContains: "What's the weather?"),
new(ChatRole.Assistant, ContentTypes: [typeof(ToolApprovalRequestContent)]),
new(ChatRole.User, ContentTypes: [typeof(ToolApprovalResponseContent)]),
new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]),
new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]),
new(ChatRole.Assistant, TextContains: "sunny and 22°C"),
@@ -196,7 +197,6 @@ public class ChatClientAgent_ApprovalsTests
agentOptions: new()
{
ChatOptions = new() { Tools = [approvalTool] },
PersistChatHistoryAtEndOfRun = false,
},
callIndex: callIndex,
capturedInputs: capturedInputs);
@@ -260,7 +260,7 @@ public class ChatClientAgent_ApprovalsTests
agentOptions: new()
{
ChatOptions = new() { Tools = [approvalTool] },
PersistChatHistoryAtEndOfRun = false,
SimulateServiceStoredChatHistory = true,
},
callIndex: callIndex,
capturedInputs: capturedInputs);
@@ -520,7 +520,7 @@ public class ChatClientAgent_ChatHistoryManagementTests
agentOptions: new()
{
ChatOptions = new() { Instructions = "Be helpful" },
PersistChatHistoryAtEndOfRun = false,
SimulateServiceStoredChatHistory = true,
},
expectedServiceCallCount: 1,
expectedHistory:
@@ -554,7 +554,7 @@ public class ChatClientAgent_ChatHistoryManagementTests
agentOptions: new()
{
ChatOptions = new() { Tools = [tool] },
PersistChatHistoryAtEndOfRun = false,
SimulateServiceStoredChatHistory = true,
},
expectedServiceCallCount: 2,
expectedHistory:
@@ -583,7 +583,6 @@ public class ChatClientAgent_ChatHistoryManagementTests
agentOptions: new()
{
ChatOptions = new() { Instructions = "Be helpful" },
PersistChatHistoryAtEndOfRun = true,
},
expectedServiceCallCount: 1,
expectedHistory:
@@ -615,7 +614,6 @@ public class ChatClientAgent_ChatHistoryManagementTests
agentOptions: new()
{
ChatOptions = new() { Tools = [tool] },
PersistChatHistoryAtEndOfRun = true,
},
expectedServiceCallCount: 2,
expectedHistory:
@@ -644,7 +642,6 @@ public class ChatClientAgent_ChatHistoryManagementTests
agentOptions: new()
{
ChatOptions = new() { Instructions = "Be helpful" },
PersistChatHistoryAtEndOfRun = false,
},
expectedServiceCallCount: 1);
@@ -176,12 +176,11 @@ public class ChatClientAgent_ChatOptionsMergingTests
}
/// <summary>
/// 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.
/// Verify that when both agent and request have no ChatOptions, the inner client
/// receives null options.
/// </summary>
[Fact]
public async Task ChatOptionsMergingReturnsChatOptionsWithNullConversationIdWhenBothAgentAndRequestHaveNoneAsync()
public async Task ChatOptionsMergingReturnsNullChatOptionsWhenBothAgentAndRequestHaveNoneAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
@@ -201,9 +200,8 @@ public class ChatClientAgent_ChatOptionsMergingTests
// Act
await agent.RunAsync(messages);
// Assert — ChatOptions is non-null because the sentinel was set, but ConversationId is null (stripped)
Assert.NotNull(capturedChatOptions);
Assert.Null(capturedChatOptions!.ConversationId);
// Assert
Assert.Null(capturedChatOptions);
}
/// <summary>
@@ -13,15 +13,15 @@ using Moq.Protected;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Contains unit tests for the <see cref="ChatHistoryPersistingChatClient"/> decorator,
/// Contains unit tests for the <see cref="ServiceStoredSimulatingChatClient"/> decorator,
/// verifying that it persists messages via the <see cref="ChatHistoryProvider"/> after each
/// individual service call by default, or marks messages for end-of-run persistence when the
/// <see cref="ChatClientAgentOptions.PersistChatHistoryAtEndOfRun"/> option is enabled.
/// <see cref="ChatClientAgentOptions.SimulateServiceStoredChatHistory"/> option is enabled.
/// </summary>
public class ChatHistoryPersistingChatClientTests
public class ServiceStoredSimulatingChatClientTests
{
/// <summary>
/// Verifies that by default (PersistChatHistoryAtEndOfRun is false),
/// Verifies that by default (SimulateServiceStoredChatHistory is false),
/// the ChatHistoryProvider receives messages after a successful non-streaming call.
/// </summary>
[Fact]
@@ -50,7 +50,7 @@ public class ChatHistoryPersistingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
PersistChatHistoryAtEndOfRun = false,
SimulateServiceStoredChatHistory = true,
});
// Act
@@ -97,7 +97,7 @@ public class ChatHistoryPersistingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
PersistChatHistoryAtEndOfRun = true,
SimulateServiceStoredChatHistory = true,
});
// Act
@@ -145,7 +145,7 @@ public class ChatHistoryPersistingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
PersistChatHistoryAtEndOfRun = false,
SimulateServiceStoredChatHistory = true,
});
// Act
@@ -163,11 +163,10 @@ public class ChatHistoryPersistingChatClientTests
}
/// <summary>
/// Verifies that the decorator is injected in persist mode by default
/// and can be discovered via GetService.
/// Verifies that the decorator is NOT injected by default (SimulateServiceStoredChatHistory is false).
/// </summary>
[Fact]
public void ChatClient_ContainsDecorator_InPersistMode_ByDefault()
public void ChatClient_DoesNotContainDecorator_ByDefault()
{
// Arrange
Mock<IChatClient> mockService = new();
@@ -176,16 +175,15 @@ public class ChatHistoryPersistingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new());
// Assert
var decorator = agent.ChatClient.GetService<ChatHistoryPersistingChatClient>();
Assert.NotNull(decorator);
Assert.False(decorator.MarkOnly);
var decorator = agent.ChatClient.GetService<ServiceStoredSimulatingChatClient>();
Assert.Null(decorator);
}
/// <summary>
/// Verifies that the decorator is injected in mark-only mode when PersistChatHistoryAtEndOfRun is true.
/// Verifies that the decorator is injected when SimulateServiceStoredChatHistory is true.
/// </summary>
[Fact]
public void ChatClient_ContainsDecorator_InMarkOnlyMode_WhenPersistAtEndOfRun()
public void ChatClient_ContainsDecorator_WhenSimulateServiceStoredChatHistory()
{
// Arrange
Mock<IChatClient> mockService = new();
@@ -193,13 +191,12 @@ public class ChatHistoryPersistingChatClientTests
// Act
ChatClientAgent agent = new(mockService.Object, options: new()
{
PersistChatHistoryAtEndOfRun = true,
SimulateServiceStoredChatHistory = true,
});
// Assert
var decorator = agent.ChatClient.GetService<ChatHistoryPersistingChatClient>();
var decorator = agent.ChatClient.GetService<ServiceStoredSimulatingChatClient>();
Assert.NotNull(decorator);
Assert.True(decorator.MarkOnly);
}
/// <summary>
@@ -218,27 +215,27 @@ public class ChatHistoryPersistingChatClientTests
});
// Assert
var decorator = agent.ChatClient.GetService<ChatHistoryPersistingChatClient>();
var decorator = agent.ChatClient.GetService<ServiceStoredSimulatingChatClient>();
Assert.Null(decorator);
}
/// <summary>
/// Verifies that the PersistChatHistoryAtEndOfRun option is included in Clone().
/// Verifies that the SimulateServiceStoredChatHistory option is included in Clone().
/// </summary>
[Fact]
public void ChatClientAgentOptions_Clone_IncludesPersistChatHistoryAtEndOfRun()
public void ChatClientAgentOptions_Clone_IncludesSimulateServiceStoredChatHistory()
{
// Arrange
var options = new ChatClientAgentOptions
{
PersistChatHistoryAtEndOfRun = true,
SimulateServiceStoredChatHistory = true,
};
// Act
var cloned = options.Clone();
// Assert
Assert.True(cloned.PersistChatHistoryAtEndOfRun);
Assert.True(cloned.SimulateServiceStoredChatHistory);
}
/// <summary>
@@ -292,7 +289,7 @@ public class ChatHistoryPersistingChatClientTests
{
ChatOptions = new() { Tools = [tool] },
ChatHistoryProvider = mockChatHistoryProvider.Object,
PersistChatHistoryAtEndOfRun = false,
SimulateServiceStoredChatHistory = true,
}, services: new ServiceCollection().BuildServiceProvider());
// Act
@@ -361,7 +358,7 @@ public class ChatHistoryPersistingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
PersistChatHistoryAtEndOfRun = false,
SimulateServiceStoredChatHistory = true,
});
// Act
@@ -410,7 +407,7 @@ public class ChatHistoryPersistingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
AIContextProviders = [mockContextProvider.Object],
PersistChatHistoryAtEndOfRun = false,
SimulateServiceStoredChatHistory = true,
});
// Act
@@ -457,7 +454,7 @@ public class ChatHistoryPersistingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
AIContextProviders = [mockContextProvider.Object],
PersistChatHistoryAtEndOfRun = false,
SimulateServiceStoredChatHistory = true,
});
// Act
@@ -516,7 +513,7 @@ public class ChatHistoryPersistingChatClientTests
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
AIContextProviders = [mockContextProvider.Object],
PersistChatHistoryAtEndOfRun = false,
SimulateServiceStoredChatHistory = true,
});
// Act
@@ -590,7 +587,7 @@ public class ChatHistoryPersistingChatClientTests
{
ChatOptions = new() { Tools = [tool] },
ChatHistoryProvider = mockChatHistoryProvider.Object,
PersistChatHistoryAtEndOfRun = false,
SimulateServiceStoredChatHistory = true,
}, services: new ServiceCollection().BuildServiceProvider());
// Act
@@ -655,7 +652,7 @@ public class ChatHistoryPersistingChatClientTests
{
ChatOptions = new() { Tools = [tool] },
ChatHistoryProvider = mockChatHistoryProvider.Object,
PersistChatHistoryAtEndOfRun = false,
SimulateServiceStoredChatHistory = true,
}, services: new ServiceCollection().BuildServiceProvider());
// Act
@@ -680,52 +677,12 @@ public class ChatHistoryPersistingChatClientTests
/// 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.
/// </summary>
[Fact]
public async Task RunAsync_MarksNotifiedMessages_WithPersistedMarkerAsync()
{
// 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")]));
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
mockChatHistoryProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(() => new ValueTask());
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
PersistChatHistoryAtEndOfRun = false,
});
// Act
var inputMessage = new ChatMessage(ChatRole.User, "test");
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await agent.RunAsync([inputMessage], session);
// Assert — input message should be marked as persisted
Assert.True(
inputMessage.AdditionalProperties?.ContainsKey(ChatHistoryPersistingChatClient.PersistedMarkerKey) == true,
"Input message should be marked as persisted after a successful run.");
}
/// <summary>
/// Verifies that when per-service-call persistence is enabled and the inner client returns a
/// conversation ID, the session's ConversationId is updated after the service call.
/// Verifies that when the inner client returns a real conversation ID,
/// the session's ConversationId is updated after the run.
/// </summary>
[Fact]
public async Task RunAsync_UpdatesSessionConversationId_WhenPerServiceCallPersistenceEnabledAsync()
public async Task RunAsync_UpdatesSessionConversationId_WhenServiceReturnsOneAsync()
{
// Arrange
const string ExpectedConversationId = "conv-123";
@@ -741,10 +698,7 @@ public class ChatHistoryPersistingChatClientTests
ConversationId = ExpectedConversationId,
});
ChatClientAgent agent = new(mockService.Object, options: new()
{
PersistChatHistoryAtEndOfRun = false,
});
ChatClientAgent agent = new(mockService.Object);
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
@@ -766,8 +720,8 @@ public class ChatHistoryPersistingChatClientTests
/// <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
/// <see cref="ChatClientAgent"/> sets the <see cref="ServiceStoredSimulatingChatClient.LocalHistoryConversationId"/>
/// sentinel on the chat options and <see cref="ServiceStoredSimulatingChatClient"/> strips it before
/// forwarding to the inner client.
/// </summary>
[Fact]
@@ -787,7 +741,7 @@ public class ChatHistoryPersistingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test" },
PersistChatHistoryAtEndOfRun = false,
SimulateServiceStoredChatHistory = true,
});
// Act
@@ -819,7 +773,7 @@ public class ChatHistoryPersistingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test" },
PersistChatHistoryAtEndOfRun = true,
SimulateServiceStoredChatHistory = true,
});
// Act
@@ -854,7 +808,7 @@ public class ChatHistoryPersistingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
PersistChatHistoryAtEndOfRun = false,
SimulateServiceStoredChatHistory = true,
});
// Create a session with a real conversation ID.
@@ -888,7 +842,7 @@ public class ChatHistoryPersistingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test" },
PersistChatHistoryAtEndOfRun = false,
SimulateServiceStoredChatHistory = true,
});
// Act
@@ -903,11 +857,12 @@ public class ChatHistoryPersistingChatClientTests
}
/// <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.
/// 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).
/// </summary>
[Fact]
public async Task RunAsync_SentinelDoesNotLeakToSession_WhenPerServiceCallPersistenceActiveAsync()
public async Task RunAsync_SetsSentinelOnSession_WhenSimulateServiceStoredChatHistoryActiveAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
@@ -920,14 +875,440 @@ public class ChatHistoryPersistingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
PersistChatHistoryAtEndOfRun = false,
SimulateServiceStoredChatHistory = true,
});
// 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);
// Assert — session should have the sentinel conversation ID
Assert.Equal(ServiceStoredSimulatingChatClient.LocalHistoryConversationId, session!.ConversationId);
}
/// <summary>
/// Verifies that when simulating service-stored chat history and the service returns
/// a real <see cref="ChatResponse.ConversationId"/>, the conflict detection in
/// <see cref="ChatClientAgent.UpdateSessionConversationId"/> throws because both a
/// <see cref="ChatHistoryProvider"/> and a service-managed ConversationId are present.
/// </summary>
[Fact]
public async Task RunAsync_Throws_WhenServiceReturnsRealConversationIdWithChatHistoryProviderAsync()
{
// Arrange
const string RealConversationId = "service-conv-456";
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
mockChatHistoryProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
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")])
{
ConversationId = RealConversationId,
});
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
});
// Act & Assert — conflict detection should throw
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], session));
}
/// <summary>
/// Verifies that when simulating service-stored chat history and the request carries a real
/// <see cref="ChatOptions.ConversationId"/>, the decorator skips history loading but still
/// notifies <see cref="AIContextProvider"/>s on success and updates the session ConversationId.
/// </summary>
[Fact]
public async Task RunAsync_NotifiesProvidersAndUpdatesSession_WhenRequestHasRealConversationIdAsync()
{
// Arrange
const string RealConversationId = "real-conv-request";
const string ServiceConversationId = "real-conv-response";
Mock<AIContextProvider> mockContextProvider = new(null, null, null);
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestContextProvider"]);
mockContextProvider
.Protected()
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<AIContext>(new AIContext { Messages = ctx.AIContext.Messages }));
mockContextProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
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")])
{
ConversationId = ServiceConversationId,
});
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = 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<ValueTask>("InvokedCoreAsync", Times.Once(),
ItExpr.Is<AIContextProvider.InvokedContext>(x =>
x.RequestMessages.Any(m => m.Text == "test") &&
x.ResponseMessages!.Any(m => m.Text == "response")),
ItExpr.IsAny<CancellationToken>());
// Assert — session should have the service-returned ConversationId
Assert.Equal(ServiceConversationId, (session as ChatClientAgentSession)!.ConversationId);
}
/// <summary>
/// Verifies that when simulating service-stored chat history and the request carries a real
/// <see cref="ChatOptions.ConversationId"/>, the decorator notifies providers of failure
/// when the inner client throws.
/// </summary>
[Fact]
public async Task RunAsync_NotifiesProvidersOfFailure_WhenRequestHasRealConversationIdAsync()
{
// Arrange
const string RealConversationId = "real-conv-failure";
Mock<AIContextProvider> mockContextProvider = new(null, null, null);
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestContextProvider"]);
mockContextProvider
.Protected()
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<AIContext>(new AIContext { Messages = ctx.AIContext.Messages }));
mockContextProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Service error"));
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
AIContextProviders = [mockContextProvider.Object],
});
var session = await agent.CreateSessionAsync(RealConversationId);
// Act & Assert — should throw
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], session));
// Assert — AIContextProvider.InvokedAsync should have been called with the failure
mockContextProvider
.Protected()
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
ItExpr.Is<AIContextProvider.InvokedContext>(x => x.InvokeException != null),
ItExpr.IsAny<CancellationToken>());
}
/// <summary>
/// Verifies that in the streaming path, when the request carries a real
/// <see cref="ChatOptions.ConversationId"/>, the decorator skips history loading but still
/// notifies providers and updates the session ConversationId.
/// </summary>
[Fact]
public async Task RunStreamingAsync_NotifiesProvidersAndUpdatesSession_WhenRequestHasRealConversationIdAsync()
{
// Arrange
const string RealConversationId = "real-conv-streaming";
const string ServiceConversationId = "service-conv-streaming";
Mock<AIContextProvider> mockContextProvider = new(null, null, null);
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestContextProvider"]);
mockContextProvider
.Protected()
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<AIContext>(new AIContext { Messages = ctx.AIContext.Messages }));
mockContextProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(CreateAsyncEnumerableAsync(
new ChatResponseUpdate(ChatRole.Assistant, "streamed") { ConversationId = ServiceConversationId }));
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = 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<ValueTask>("InvokedCoreAsync", Times.Once(),
ItExpr.IsAny<AIContextProvider.InvokedContext>(),
ItExpr.IsAny<CancellationToken>());
// Assert — session should have the service-returned ConversationId
Assert.Equal(ServiceConversationId, (session as ChatClientAgentSession)!.ConversationId);
}
/// <summary>
/// Verifies that when simulating and the service unexpectedly returns a real
/// <see cref="ChatResponse.ConversationId"/> (no ConversationId on the request), the decorator
/// notifies providers and updates the session ConversationId without setting the sentinel.
/// </summary>
[Fact]
public async Task RunAsync_NotifiesProvidersAndUpdatesSession_WhenServiceReturnsUnexpectedConversationIdAsync()
{
// Arrange
const string ServiceConversationId = "unexpected-conv-id";
Mock<AIContextProvider> mockContextProvider = new(null, null, null);
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestContextProvider"]);
mockContextProvider
.Protected()
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<AIContext>(new AIContext { Messages = ctx.AIContext.Messages }));
mockContextProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
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")])
{
ConversationId = ServiceConversationId,
});
// No ChatHistoryProvider — so conflict detection won't throw.
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
AIContextProviders = [mockContextProvider.Object],
});
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await agent.RunAsync([new(ChatRole.User, "test")], session);
// Assert — AIContextProvider.InvokedAsync should have been called
mockContextProvider
.Protected()
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
ItExpr.Is<AIContextProvider.InvokedContext>(x =>
x.ResponseMessages!.Any(m => m.Text == "response")),
ItExpr.IsAny<CancellationToken>());
// Assert — session should have the service ConversationId, not the sentinel
Assert.Equal(ServiceConversationId, session!.ConversationId);
}
/// <summary>
/// Verifies that in the streaming path, when the service returns a real ConversationId mid-stream
/// (no ConversationId on the request), the decorator notifies providers and updates the session.
/// </summary>
[Fact]
public async Task RunStreamingAsync_NotifiesProvidersAndUpdatesSession_WhenServiceReturnsUnexpectedConversationIdAsync()
{
// Arrange
const string ServiceConversationId = "unexpected-stream-conv";
Mock<AIContextProvider> mockContextProvider = new(null, null, null);
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestContextProvider"]);
mockContextProvider
.Protected()
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<AIContext>(new AIContext { Messages = ctx.AIContext.Messages }));
mockContextProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(CreateAsyncEnumerableAsync(
new ChatResponseUpdate(ChatRole.Assistant, "part1"),
new ChatResponseUpdate(null, "part2") { ConversationId = ServiceConversationId }));
// No ChatHistoryProvider — so conflict detection won't throw.
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
AIContextProviders = [mockContextProvider.Object],
});
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await foreach (var _ in agent.RunStreamingAsync([new(ChatRole.User, "test")], session))
{
// Consume all updates.
}
// Assert — AIContextProvider.InvokedAsync should have been called
mockContextProvider
.Protected()
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
ItExpr.IsAny<AIContextProvider.InvokedContext>(),
ItExpr.IsAny<CancellationToken>());
// Assert — session should have the service ConversationId, not the sentinel
Assert.Equal(ServiceConversationId, session!.ConversationId);
}
/// <summary>
/// Verifies that when <see cref="ChatOptions.AllowBackgroundResponses"/> is true,
/// the decorator skips history loading and sentinel setting, letting the agent's
/// forced end-of-run path handle persistence.
/// </summary>
[Fact]
public async Task RunAsync_SkipsSimulation_WhenAllowBackgroundResponsesAsync()
{
// Arrange
IEnumerable<ChatMessage>? capturedMessages = 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>((msgs, _, _) => capturedMessages = msgs)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
{
// Add a history message to verify it's NOT prepended in this scenario.
var result = ctx.RequestMessages.ToList();
result.Insert(0, new ChatMessage(ChatRole.Assistant, "history"));
return new ValueTask<IEnumerable<ChatMessage>>(result);
});
mockChatHistoryProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
});
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await agent.RunAsync(
[new(ChatRole.User, "test")],
session,
new AgentRunOptions { AllowBackgroundResponses = true });
// Assert — the inner client should NOT have received history messages
Assert.NotNull(capturedMessages);
var messageList = capturedMessages!.ToList();
Assert.Single(messageList);
Assert.Equal("test", messageList[0].Text);
// Assert — session should NOT have the sentinel (agent handles ConversationId at end-of-run)
Assert.NotEqual(ServiceStoredSimulatingChatClient.LocalHistoryConversationId, session!.ConversationId);
}
/// <summary>
/// Verifies that in the streaming path, when <see cref="ChatOptions.AllowBackgroundResponses"/> is true,
/// the decorator skips history loading and sentinel setting.
/// </summary>
[Fact]
public async Task RunStreamingAsync_SkipsSimulation_WhenAllowBackgroundResponsesAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(CreateAsyncEnumerableAsync(new ChatResponseUpdate(ChatRole.Assistant, "response")));
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
});
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
List<AgentResponseUpdate> updates = [];
await foreach (var update in agent.RunStreamingAsync(
[new(ChatRole.User, "test")],
session,
new AgentRunOptions { AllowBackgroundResponses = true }))
{
updates.Add(update);
}
// Assert — updates should NOT carry the sentinel ConversationId
Assert.NotEmpty(updates);
// Assert — session should NOT have the sentinel
Assert.NotEqual(ServiceStoredSimulatingChatClient.LocalHistoryConversationId, session!.ConversationId);
}
}