// Copyright (c) Microsoft. All rights reserved. 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; /// /// Unit tests for . /// public class MessageInjectingChatClientTests { /// /// Verifies that is resolvable via GetService when the decorator is active. /// [Fact] public void GetService_ReturnsMessageInjectingChatClient_WhenDecoratorActive() { // Arrange Mock mockService = new(); ChatClientAgent agent = new(mockService.Object, options: new() { EnableMessageInjection = true, }); // Act var injector = agent.ChatClient.GetService(); // Assert Assert.NotNull(injector); } /// /// Verifies that is null when the decorator is not active. /// [Fact] public void GetService_ReturnsNull_WhenDecoratorNotActive() { // Arrange Mock mockService = new(); ChatClientAgent agent = new(mockService.Object, options: new()); // Act var injector = agent.ChatClient.GetService(); // Assert Assert.Null(injector); } /// /// Verifies that messages enqueued on the session before RunAsync are included in the service call messages. /// [Fact] public async Task RunAsync_IncludesInjectedMessages_WhenEnqueuedBeforeCallAsync() { // Arrange List capturedMessages = []; Mock mockService = new(); mockService.Setup( s => s.GetResponseAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .Callback((IEnumerable msgs, ChatOptions? _, CancellationToken _) => capturedMessages.AddRange(msgs)) .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, EnableMessageInjection = true, }); // Create session and enqueue a message directly onto the session's StateBag queue before calling RunAsync var session = await agent.CreateSessionAsync() as ChatClientAgentSession; var queue = new List(); queue.Add(new ChatMessage(ChatRole.User, "injected message")); session!.StateBag.SetValue("MessageInjectingChatClient.PendingInjectedMessages", queue); // Act await agent.RunAsync([new(ChatRole.User, "original")], session); // Assert — the service should have received both the original and injected messages Assert.Contains(capturedMessages, m => m.Text == "original"); Assert.Contains(capturedMessages, m => m.Text == "injected message"); } /// /// Verifies that the queue is drained after a call (messages are not re-delivered on subsequent calls). /// [Fact] public async Task RunAsync_DrainsQueue_MessagesNotRedeliveredAsync() { // Arrange List capturedMessages = []; Mock mockService = new(); mockService.Setup( s => s.GetResponseAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .Callback((IEnumerable msgs, ChatOptions? _, CancellationToken _) => capturedMessages.AddRange(msgs)) .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, EnableMessageInjection = true, }); // Create session and enqueue a message directly onto the session's StateBag queue var session = await agent.CreateSessionAsync() as ChatClientAgentSession; var queue = new List(); queue.Add(new ChatMessage(ChatRole.User, "injected once")); session!.StateBag.SetValue("MessageInjectingChatClient.PendingInjectedMessages", queue); // Act await agent.RunAsync([new(ChatRole.User, "first call")], session); // Assert — the injected message was included in the service call Assert.Contains(capturedMessages, m => m.Text == "injected once"); // Assert — the session's queue is now empty (drained) Assert.Empty(queue); } /// /// Verifies that the internal loop fires when no actionable FunctionCallContent is returned /// but there are pending injected messages in the queue. /// [Fact] public async Task RunAsync_LoopsInternally_WhenNoActionableFCCButPendingMessagesAsync() { // Arrange int serviceCallCount = 0; Mock mockService = new(); MessageInjectingChatClient? injectorRef = null; ChatClientAgentSession? sessionRef = null; mockService.Setup( s => s.GetResponseAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .Returns((IEnumerable msgs, ChatOptions? _, CancellationToken _) => { serviceCallCount++; if (serviceCallCount == 1) { // First call — simulate that something enqueues a message (e.g., a provider or background task) injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected during first call")]); } // Return a plain text response (no FunctionCallContent) to trigger the internal loop return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, $"response {serviceCallCount}")])); }); 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, EnableMessageInjection = true, }); injectorRef = agent.ChatClient.GetService()!; // Act var session = await agent.CreateSessionAsync() as ChatClientAgentSession; sessionRef = session; await agent.RunAsync([new(ChatRole.User, "original")], session); // Assert — should have made 2 service calls (internal loop triggered by the injected message) Assert.Equal(2, serviceCallCount); } /// /// Verifies that the internal loop does NOT fire when the response contains actionable /// FunctionCallContent, even if there are pending injected messages. /// [Fact] public async Task RunAsync_DoesNotLoopInternally_WhenActionableFCCPresentAsync() { // Arrange int serviceCallCount = 0; Mock mockService = new(); MessageInjectingChatClient? injectorRef = null; ChatClientAgentSession? sessionRef = null; mockService.Setup( s => s.GetResponseAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .Returns((IEnumerable msgs, ChatOptions? _, CancellationToken _) => { serviceCallCount++; if (serviceCallCount == 1) { // Enqueue a message during the first call injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected")]); // Return a response with an actionable FunctionCallContent return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, [new FunctionCallContent("call1", "myTool", new Dictionary())])])); } // Subsequent calls return plain text (the FCC loop will call back after tool execution) return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final")])); }); 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()); 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, EnableMessageInjection = true, }, services: new ServiceCollection().BuildServiceProvider()); injectorRef = agent.ChatClient.GetService()!; // Act var session = await agent.CreateSessionAsync() as ChatClientAgentSession; sessionRef = session; await agent.RunAsync([new(ChatRole.User, "original")], session); // Assert — The first service call returned actionable FCC, so no internal injected-message loop // occurred there. The FCC loop invokes the tool and calls the service again (second call). // The injected message should be picked up by the second service call (drained at start of // GetResponseAsync), but no extra internal loop should fire. Exactly 2 service calls expected. Assert.Equal(2, serviceCallCount); } /// /// Verifies that the internal loop fires when the response contains only InformationalOnly /// FunctionCallContent (which are not actionable) and there are pending injected messages. /// [Fact] public async Task RunAsync_LoopsInternally_WhenOnlyInformationalOnlyFCCAndPendingMessagesAsync() { // Arrange int serviceCallCount = 0; Mock mockService = new(); MessageInjectingChatClient? injectorRef = null; ChatClientAgentSession? sessionRef = null; mockService.Setup( s => s.GetResponseAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .Returns((IEnumerable msgs, ChatOptions? _, CancellationToken _) => { serviceCallCount++; if (serviceCallCount == 1) { // Enqueue a message during the first call injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected")]); // Return a response with InformationalOnly FCC (not actionable) return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, [new FunctionCallContent("call1", "myTool", new Dictionary()) { InformationalOnly = true }])])); } return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final")])); }); 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, EnableMessageInjection = true, }); injectorRef = agent.ChatClient.GetService()!; // Act var session = await agent.CreateSessionAsync() as ChatClientAgentSession; sessionRef = session; await agent.RunAsync([new(ChatRole.User, "original")], session); // Assert — InformationalOnly FCC is NOT actionable, so internal loop should trigger Assert.Equal(2, serviceCallCount); } /// /// Verifies that when the inner client returns a ConversationId on the first call, the /// MessageInjectingChatClient propagates it to options on subsequent loop iterations. /// [Fact] public async Task RunAsync_PropagatesConversationId_AcrossInternalLoopIterationsAsync() { // Arrange int serviceCallCount = 0; List capturedConversationIds = []; MessageInjectingChatClient? injectorRef = null; ChatClientAgentSession? sessionRef = null; Mock mockService = new(); mockService.Setup( s => s.GetResponseAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .Returns((IEnumerable _, ChatOptions? opts, CancellationToken _) => { serviceCallCount++; capturedConversationIds.Add(opts?.ConversationId); if (serviceCallCount == 1) { // First call: inject a message and return a ConversationId injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected")]); return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "first response")]) { ConversationId = "conv-123", }); } // Second call (from loop): should have the propagated ConversationId return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "second response")])); }); ChatClientAgent agent = new(mockService.Object, options: new() { EnableMessageInjection = true, }, services: new ServiceCollection().BuildServiceProvider()); injectorRef = agent.ChatClient.GetService()!; // Act var session = await agent.CreateSessionAsync() as ChatClientAgentSession; sessionRef = session; await agent.RunAsync([new(ChatRole.User, "hello")], session); // Assert — The second call should have received the ConversationId propagated from the first response Assert.Equal(2, serviceCallCount); Assert.Null(capturedConversationIds[0]); // First call: no ConversationId yet Assert.Equal("conv-123", capturedConversationIds[1]); // Second call: propagated from first response } /// /// Verifies that a session with pending injected messages can be serialized and deserialized, /// and that the deserialized session correctly delivers the injected messages on the next run. /// [Fact] public async Task RunAsync_DeliversInjectedMessages_AfterSessionSerializationRoundTripAsync() { // Arrange List capturedMessagesFirstRun = []; List capturedMessagesSecondRun = []; int runCount = 0; Mock mockService = new(); MessageInjectingChatClient? injectorRef = null; ChatClientAgentSession? sessionRef = null; mockService.Setup( s => s.GetResponseAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .Returns((IEnumerable msgs, ChatOptions? _, CancellationToken _) => { if (runCount == 1) { capturedMessagesFirstRun.AddRange(msgs); // Inject a message during the first run — this will remain pending (not drained) // because we return an actionable FCC that causes the parent loop to take over. injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected before serialization")]); // Return actionable FCC so the injection loop does NOT drain the message return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, [new FunctionCallContent("call1", "myTool", new Dictionary())])])); } // Second run (after deserialization) — capture what messages come through capturedMessagesSecondRun.AddRange(msgs); return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final 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()); 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, EnableMessageInjection = true, }, services: new ServiceCollection().BuildServiceProvider()); injectorRef = agent.ChatClient.GetService()!; // Act — First run: inject a message that stays pending var session = await agent.CreateSessionAsync() as ChatClientAgentSession; sessionRef = session; runCount = 1; await agent.RunAsync([new(ChatRole.User, "first run message")], session); // Serialize the session and deserialize into a new instance var serialized = await agent.SerializeSessionAsync(session!); var deserializedSession = await agent.DeserializeSessionAsync(serialized) as ChatClientAgentSession; // Second run on the deserialized session — the injected message should be delivered runCount = 2; sessionRef = deserializedSession; await agent.RunAsync([new(ChatRole.User, "second run message")], deserializedSession); // Assert — the second run should include the injected message from before serialization Assert.Contains(capturedMessagesSecondRun, m => m.Text == "injected before serialization"); Assert.Contains(capturedMessagesSecondRun, m => m.Text == "second run message"); } }