chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
This commit is contained in:
+666
@@ -0,0 +1,666 @@
|
||||
// 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.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
public class ApprovalNotRequiredFunctionBypassingChatClientTests
|
||||
{
|
||||
#region GetResponseAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_NoApprovalContent_PassesThroughUnchangedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var innerClient = CreateMockChatClient((_, _, _) =>
|
||||
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Hello")])));
|
||||
|
||||
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
|
||||
var session = new ChatClientAgentSession();
|
||||
|
||||
// Act
|
||||
var response = await RunWithAgentContextAsync(decorator, session);
|
||||
|
||||
// Assert
|
||||
Assert.Single(response.Messages);
|
||||
Assert.Equal("Hello", response.Messages[0].Text);
|
||||
Assert.Equal(0, session.StateBag.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_AllToolsRequireApproval_PassesThroughUnchangedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "approvalTool"));
|
||||
var fcc = new FunctionCallContent("call1", "approvalTool");
|
||||
var approval = new ToolApprovalRequestContent("req1", fcc);
|
||||
|
||||
var innerClient = CreateMockChatClient((_, _, _) =>
|
||||
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, [approval])])));
|
||||
|
||||
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
|
||||
var session = new ChatClientAgentSession();
|
||||
var options = new ChatOptions { Tools = [approvalTool] };
|
||||
|
||||
// Act
|
||||
var response = await RunWithAgentContextAsync(decorator, session, options);
|
||||
|
||||
// Assert — approval request should remain
|
||||
Assert.Single(response.Messages);
|
||||
var contents = response.Messages[0].Contents;
|
||||
Assert.Single(contents);
|
||||
Assert.IsType<ToolApprovalRequestContent>(contents[0]);
|
||||
Assert.Equal(0, session.StateBag.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_MixedApproval_RemovesApprovalNotRequiredItemsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
|
||||
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "approvalTool"));
|
||||
|
||||
var fccNormal = new FunctionCallContent("call1", "normalTool");
|
||||
var fccApproval = new FunctionCallContent("call2", "approvalTool");
|
||||
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
|
||||
var approvalRequired = new ToolApprovalRequestContent("req2", fccApproval);
|
||||
|
||||
var innerClient = CreateMockChatClient((_, _, _) =>
|
||||
Task.FromResult(new ChatResponse([
|
||||
new ChatMessage(ChatRole.Assistant, [approvalNormal, approvalRequired])
|
||||
])));
|
||||
|
||||
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
|
||||
var session = new ChatClientAgentSession();
|
||||
var options = new ChatOptions { Tools = [normalTool, approvalTool] };
|
||||
|
||||
// Act
|
||||
var response = await RunWithAgentContextAsync(decorator, session, options);
|
||||
|
||||
// Assert — only the approval-required item remains in the response
|
||||
Assert.Single(response.Messages);
|
||||
var contents = response.Messages[0].Contents;
|
||||
Assert.Single(contents);
|
||||
var remainingApproval = Assert.IsType<ToolApprovalRequestContent>(contents[0]);
|
||||
Assert.Equal("req2", remainingApproval.RequestId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_MixedApproval_StoresAutoApprovedInSessionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
|
||||
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "approvalTool"));
|
||||
|
||||
var fccNormal = new FunctionCallContent("call1", "normalTool");
|
||||
var fccApproval = new FunctionCallContent("call2", "approvalTool");
|
||||
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
|
||||
var approvalRequired = new ToolApprovalRequestContent("req2", fccApproval);
|
||||
|
||||
var innerClient = CreateMockChatClient((_, _, _) =>
|
||||
Task.FromResult(new ChatResponse([
|
||||
new ChatMessage(ChatRole.Assistant, [approvalNormal, approvalRequired])
|
||||
])));
|
||||
|
||||
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
|
||||
var session = new ChatClientAgentSession();
|
||||
var options = new ChatOptions { Tools = [normalTool, approvalTool] };
|
||||
|
||||
// Act
|
||||
await RunWithAgentContextAsync(decorator, session, options);
|
||||
|
||||
// Assert — the auto-approved item should be stored in the session
|
||||
Assert.True(session.StateBag.TryGetValue<List<ToolApprovalRequestContent>>(
|
||||
ApprovalNotRequiredFunctionBypassingChatClient.StateBagKey, out var stored, AgentJsonUtilities.DefaultOptions));
|
||||
Assert.NotNull(stored);
|
||||
Assert.Single(stored!);
|
||||
Assert.Equal("req1", stored![0].RequestId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_AllApprovalNotRequired_RemovesAllApprovalsAndRemovesEmptyMessageAsync()
|
||||
{
|
||||
// Arrange
|
||||
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
|
||||
|
||||
var fccNormal = new FunctionCallContent("call1", "normalTool");
|
||||
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
|
||||
|
||||
var innerClient = CreateMockChatClient((_, _, _) =>
|
||||
Task.FromResult(new ChatResponse([
|
||||
new ChatMessage(ChatRole.Assistant, [approvalNormal])
|
||||
])));
|
||||
|
||||
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
|
||||
var session = new ChatClientAgentSession();
|
||||
var options = new ChatOptions { Tools = [normalTool] };
|
||||
|
||||
// Act
|
||||
var response = await RunWithAgentContextAsync(decorator, session, options);
|
||||
|
||||
// Assert — the message should be removed since it's now empty
|
||||
Assert.Empty(response.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_PreExistingEmptyMessage_IsPreservedAsync()
|
||||
{
|
||||
// Arrange — the response contains a metadata-only message that was already empty
|
||||
// (no content items) before the decorator runs, alongside a normal text message.
|
||||
var innerClient = CreateMockChatClient((_, _, _) =>
|
||||
Task.FromResult(new ChatResponse([
|
||||
new ChatMessage(ChatRole.Assistant, contents: []),
|
||||
new ChatMessage(ChatRole.Assistant, "Hello")
|
||||
])));
|
||||
|
||||
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
|
||||
var session = new ChatClientAgentSession();
|
||||
|
||||
// Act
|
||||
var response = await RunWithAgentContextAsync(decorator, session);
|
||||
|
||||
// Assert — the decorator must not drop a message it did not empty itself.
|
||||
Assert.Equal(2, response.Messages.Count);
|
||||
Assert.Empty(response.Messages[0].Contents);
|
||||
Assert.Equal("Hello", response.Messages[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_NextRequest_InjectsStoredAutoApprovalsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var fccNormal = new FunctionCallContent("call1", "normalTool");
|
||||
var storedApproval = new ToolApprovalRequestContent("req1", fccNormal);
|
||||
|
||||
var session = new ChatClientAgentSession();
|
||||
session.StateBag.SetValue(
|
||||
ApprovalNotRequiredFunctionBypassingChatClient.StateBagKey,
|
||||
new List<ToolApprovalRequestContent> { storedApproval },
|
||||
AgentJsonUtilities.DefaultOptions);
|
||||
|
||||
IEnumerable<ChatMessage>? capturedMessages = null;
|
||||
var innerClient = CreateMockChatClient((messages, _, _) =>
|
||||
{
|
||||
capturedMessages = messages.ToList();
|
||||
return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Done")]));
|
||||
});
|
||||
|
||||
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
|
||||
var options = new ChatOptions { Tools = [AIFunctionFactory.Create(() => "result", "normalTool")] };
|
||||
|
||||
// Act
|
||||
await RunWithAgentContextAsync(decorator, session, options);
|
||||
|
||||
// Assert — the inner client should receive injected messages
|
||||
Assert.NotNull(capturedMessages);
|
||||
var messagesList = capturedMessages!.ToList();
|
||||
|
||||
// Original user message + user message with approved responses.
|
||||
Assert.Equal(2, messagesList.Count);
|
||||
Assert.Equal(ChatRole.User, messagesList[0].Role);
|
||||
|
||||
// User message with the auto-approved ToolApprovalResponseContent
|
||||
Assert.Equal(ChatRole.User, messagesList[1].Role);
|
||||
var userContent = messagesList[1].Contents.OfType<ToolApprovalResponseContent>().ToList();
|
||||
Assert.Single(userContent);
|
||||
Assert.Equal("req1", userContent[0].RequestId);
|
||||
Assert.True(userContent[0].Approved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_NextRequest_ClearsStoredAfterInjectionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var fccNormal = new FunctionCallContent("call1", "normalTool");
|
||||
var storedApproval = new ToolApprovalRequestContent("req1", fccNormal);
|
||||
|
||||
var session = new ChatClientAgentSession();
|
||||
session.StateBag.SetValue(
|
||||
ApprovalNotRequiredFunctionBypassingChatClient.StateBagKey,
|
||||
new List<ToolApprovalRequestContent> { storedApproval },
|
||||
AgentJsonUtilities.DefaultOptions);
|
||||
|
||||
var innerClient = CreateMockChatClient((_, _, _) =>
|
||||
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Done")])));
|
||||
|
||||
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
|
||||
var options = new ChatOptions { Tools = [AIFunctionFactory.Create(() => "result", "normalTool")] };
|
||||
|
||||
// Act
|
||||
await RunWithAgentContextAsync(decorator, session, options);
|
||||
|
||||
// Assert — the stored data should be cleared after successful injection
|
||||
Assert.False(session.StateBag.TryGetValue<List<ToolApprovalRequestContent>>(
|
||||
ApprovalNotRequiredFunctionBypassingChatClient.StateBagKey, out _, AgentJsonUtilities.DefaultOptions));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_UnknownTool_TreatedAsApprovalRequiredAsync()
|
||||
{
|
||||
// Arrange — tool is not in ChatOptions.Tools
|
||||
var fccUnknown = new FunctionCallContent("call1", "unknownTool");
|
||||
var approvalUnknown = new ToolApprovalRequestContent("req1", fccUnknown);
|
||||
|
||||
var innerClient = CreateMockChatClient((_, _, _) =>
|
||||
Task.FromResult(new ChatResponse([
|
||||
new ChatMessage(ChatRole.Assistant, [approvalUnknown])
|
||||
])));
|
||||
|
||||
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
|
||||
var session = new ChatClientAgentSession();
|
||||
var options = new ChatOptions { Tools = [] };
|
||||
|
||||
// Act
|
||||
var response = await RunWithAgentContextAsync(decorator, session, options);
|
||||
|
||||
// Assert — unknown tool should NOT be auto-approved
|
||||
Assert.Single(response.Messages);
|
||||
Assert.Single(response.Messages[0].Contents);
|
||||
Assert.IsType<ToolApprovalRequestContent>(response.Messages[0].Contents[0]);
|
||||
Assert.Equal(0, session.StateBag.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_StoredRequestToolSetChanged_StillInjectsAsApprovedAsync()
|
||||
{
|
||||
// Arrange — tool was previously non-approval-required but is now wrapped in ApprovalRequiredAIFunction.
|
||||
// The LLM still requires a complete set of responses, so we inject unconditionally.
|
||||
var fccTool = new FunctionCallContent("call1", "changingTool");
|
||||
var storedApproval = new ToolApprovalRequestContent("req1", fccTool);
|
||||
|
||||
var session = new ChatClientAgentSession();
|
||||
session.StateBag.SetValue(
|
||||
ApprovalNotRequiredFunctionBypassingChatClient.StateBagKey,
|
||||
new List<ToolApprovalRequestContent> { storedApproval },
|
||||
AgentJsonUtilities.DefaultOptions);
|
||||
|
||||
IEnumerable<ChatMessage>? capturedMessages = null;
|
||||
var innerClient = CreateMockChatClient((messages, _, _) =>
|
||||
{
|
||||
capturedMessages = messages.ToList();
|
||||
return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Done")]));
|
||||
});
|
||||
|
||||
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
|
||||
|
||||
// The tool is now wrapped in ApprovalRequiredAIFunction — but we still inject unconditionally
|
||||
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "changingTool"));
|
||||
var options = new ChatOptions { Tools = [approvalTool] };
|
||||
|
||||
// Act
|
||||
await RunWithAgentContextAsync(decorator, session, options);
|
||||
|
||||
// Assert — the stored request should still be injected as approved
|
||||
Assert.NotNull(capturedMessages);
|
||||
var messagesList = capturedMessages!.ToList();
|
||||
Assert.Equal(2, messagesList.Count);
|
||||
var userContent = messagesList[1].Contents.OfType<ToolApprovalResponseContent>().ToList();
|
||||
Assert.Single(userContent);
|
||||
Assert.Equal("req1", userContent[0].RequestId);
|
||||
Assert.True(userContent[0].Approved);
|
||||
|
||||
// Session should be cleared
|
||||
Assert.False(session.StateBag.TryGetValue<List<ToolApprovalRequestContent>>(
|
||||
ApprovalNotRequiredFunctionBypassingChatClient.StateBagKey, out _, AgentJsonUtilities.DefaultOptions));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetStreamingResponseAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetStreamingResponseAsync_NoApprovalContent_PassesThroughUnchangedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var innerClient = CreateMockStreamingChatClient((_, _, _) =>
|
||||
ToAsyncEnumerableAsync(
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "Hello")));
|
||||
|
||||
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
|
||||
var session = new ChatClientAgentSession();
|
||||
|
||||
// Act
|
||||
var updates = new List<ChatResponseUpdate>();
|
||||
await RunStreamingWithAgentContextAsync(decorator, session, updates);
|
||||
|
||||
// Assert
|
||||
Assert.Single(updates);
|
||||
Assert.Equal("Hello", updates[0].Text);
|
||||
Assert.Equal(0, session.StateBag.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStreamingResponseAsync_MixedApproval_FiltersApprovalNotRequiredItemsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
|
||||
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "approvalTool"));
|
||||
|
||||
var fccNormal = new FunctionCallContent("call1", "normalTool");
|
||||
var fccApproval = new FunctionCallContent("call2", "approvalTool");
|
||||
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
|
||||
var approvalRequired = new ToolApprovalRequestContent("req2", fccApproval);
|
||||
|
||||
var innerClient = CreateMockStreamingChatClient((_, _, _) =>
|
||||
ToAsyncEnumerableAsync(
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "text"),
|
||||
new ChatResponseUpdate { Contents = [approvalNormal, approvalRequired] }));
|
||||
|
||||
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
|
||||
var session = new ChatClientAgentSession();
|
||||
var options = new ChatOptions { Tools = [normalTool, approvalTool] };
|
||||
|
||||
// Act
|
||||
var updates = new List<ChatResponseUpdate>();
|
||||
await RunStreamingWithAgentContextAsync(decorator, session, updates, options);
|
||||
|
||||
// Assert — text update + filtered approval update
|
||||
Assert.Equal(2, updates.Count);
|
||||
Assert.Equal("text", updates[0].Text);
|
||||
|
||||
// Second update should only have the approval-required item
|
||||
var approvalContents = updates[1].Contents.OfType<ToolApprovalRequestContent>().ToList();
|
||||
Assert.Single(approvalContents);
|
||||
Assert.Equal("req2", approvalContents[0].RequestId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStreamingResponseAsync_MixedApproval_StoresAutoApprovedInSessionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
|
||||
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "approvalTool"));
|
||||
|
||||
var fccNormal = new FunctionCallContent("call1", "normalTool");
|
||||
var fccApproval = new FunctionCallContent("call2", "approvalTool");
|
||||
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
|
||||
var approvalRequired = new ToolApprovalRequestContent("req2", fccApproval);
|
||||
|
||||
var innerClient = CreateMockStreamingChatClient((_, _, _) =>
|
||||
ToAsyncEnumerableAsync(
|
||||
new ChatResponseUpdate { Contents = [approvalNormal, approvalRequired] }));
|
||||
|
||||
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
|
||||
var session = new ChatClientAgentSession();
|
||||
var options = new ChatOptions { Tools = [normalTool, approvalTool] };
|
||||
|
||||
// Act
|
||||
var updates = new List<ChatResponseUpdate>();
|
||||
await RunStreamingWithAgentContextAsync(decorator, session, updates, options);
|
||||
|
||||
// Assert — the auto-approved item should be stored in the session
|
||||
Assert.True(session.StateBag.TryGetValue<List<ToolApprovalRequestContent>>(
|
||||
ApprovalNotRequiredFunctionBypassingChatClient.StateBagKey, out var stored, AgentJsonUtilities.DefaultOptions));
|
||||
Assert.NotNull(stored);
|
||||
Assert.Single(stored!);
|
||||
Assert.Equal("req1", stored![0].RequestId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStreamingResponseAsync_AllApprovalNotRequired_SkipsEmptyUpdateAsync()
|
||||
{
|
||||
// Arrange
|
||||
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
|
||||
|
||||
var fccNormal = new FunctionCallContent("call1", "normalTool");
|
||||
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
|
||||
|
||||
var innerClient = CreateMockStreamingChatClient((_, _, _) =>
|
||||
ToAsyncEnumerableAsync(
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "text"),
|
||||
new ChatResponseUpdate { Contents = [approvalNormal] }));
|
||||
|
||||
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
|
||||
var session = new ChatClientAgentSession();
|
||||
var options = new ChatOptions { Tools = [normalTool] };
|
||||
|
||||
// Act
|
||||
var updates = new List<ChatResponseUpdate>();
|
||||
await RunStreamingWithAgentContextAsync(decorator, session, updates, options);
|
||||
|
||||
// Assert — the approval update should be skipped entirely
|
||||
Assert.Single(updates);
|
||||
Assert.Equal("text", updates[0].Text);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region No-Context Pass-Through Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_NoRunContext_PassesThroughWithoutBypassingAsync()
|
||||
{
|
||||
// Arrange
|
||||
var fcc = new FunctionCallContent("call1", "normalTool");
|
||||
var approval = new ToolApprovalRequestContent("req1", fcc);
|
||||
var innerClient = CreateMockChatClient((_, _, _) =>
|
||||
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, [approval])])));
|
||||
|
||||
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
|
||||
|
||||
// Act — calling directly without agent context; the decorator should no-op and pass through.
|
||||
var response = await decorator.GetResponseAsync([new ChatMessage(ChatRole.User, "test")]);
|
||||
|
||||
// Assert — the approval request is surfaced to the caller unchanged (not bypassed).
|
||||
var contents = response.Messages.Single().Contents;
|
||||
Assert.IsType<ToolApprovalRequestContent>(Assert.Single(contents));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_NoSession_PassesThroughWithoutBypassingAsync()
|
||||
{
|
||||
// Arrange
|
||||
var fcc = new FunctionCallContent("call1", "normalTool");
|
||||
var approval = new ToolApprovalRequestContent("req1", fcc);
|
||||
var innerClient = CreateMockChatClient((_, _, _) =>
|
||||
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, [approval])])));
|
||||
|
||||
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
|
||||
|
||||
// Act — run with an agent context but a null session; the decorator should no-op and pass through.
|
||||
var response = await RunWithAgentContextAsync(decorator, session: null!);
|
||||
|
||||
// Assert — the approval request is surfaced to the caller unchanged (not bypassed).
|
||||
var contents = response.Messages.Single().Contents;
|
||||
Assert.IsType<ToolApprovalRequestContent>(Assert.Single(contents));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStreamingResponseAsync_NoRunContext_PassesThroughWithoutBypassingAsync()
|
||||
{
|
||||
// Arrange
|
||||
var fcc = new FunctionCallContent("call1", "normalTool");
|
||||
var approval = new ToolApprovalRequestContent("req1", fcc);
|
||||
var innerClient = CreateMockStreamingChatClient((_, _, _) =>
|
||||
ToAsyncEnumerableAsync(
|
||||
new ChatResponseUpdate(ChatRole.Assistant, [approval])));
|
||||
|
||||
var decorator = new ApprovalNotRequiredFunctionBypassingChatClient(innerClient);
|
||||
|
||||
// Act — calling directly without agent context; the decorator should no-op and pass through.
|
||||
var updates = new List<ChatResponseUpdate>();
|
||||
await foreach (var update in decorator.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "test")]))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert — the approval request is surfaced to the caller unchanged (not bypassed).
|
||||
Assert.Contains(updates.SelectMany(u => u.Contents), c => c is ToolApprovalRequestContent);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Builder Extension Tests
|
||||
|
||||
[Fact]
|
||||
public void UseApprovalNotRequiredFunctionBypassing_AddsDecoratorToPipeline()
|
||||
{
|
||||
// Arrange
|
||||
var innerClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var pipeline = innerClient.AsBuilder()
|
||||
.UseApprovalNotRequiredFunctionBypassing()
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(pipeline.GetService<ApprovalNotRequiredFunctionBypassingChatClient>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UseApprovalNotRequiredFunctionBypassing_ExplicitLoggerFactory_IsUsedForWarningAsync()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger>();
|
||||
loggerMock.Setup(l => l.IsEnabled(LogLevel.Warning)).Returns(true);
|
||||
var loggerFactoryMock = new Mock<ILoggerFactory>();
|
||||
loggerFactoryMock.Setup(f => f.CreateLogger(It.IsAny<string>())).Returns(loggerMock.Object);
|
||||
|
||||
var fcc = new FunctionCallContent("call1", "normalTool");
|
||||
var approval = new ToolApprovalRequestContent("req1", fcc);
|
||||
var innerClient = CreateMockChatClient((_, _, _) =>
|
||||
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, [approval])])));
|
||||
|
||||
var pipeline = innerClient.AsBuilder()
|
||||
.UseApprovalNotRequiredFunctionBypassing(loggerFactoryMock.Object)
|
||||
.Build();
|
||||
|
||||
// Act — invoked without an agent run context, so the decorator no-ops and logs a warning
|
||||
// via the explicitly provided logger factory.
|
||||
var response = await pipeline.GetResponseAsync([new ChatMessage(ChatRole.User, "test")]);
|
||||
|
||||
// Assert — the provided factory was used to emit a warning, and the approval request is surfaced.
|
||||
loggerFactoryMock.Verify(f => f.CreateLogger(It.IsAny<string>()), Times.Once);
|
||||
loggerMock.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.IsAny<It.IsAnyType>(),
|
||||
It.IsAny<Exception?>(),
|
||||
(Func<It.IsAnyType, Exception?, string>)It.IsAny<object>()),
|
||||
Times.Once);
|
||||
Assert.IsType<ToolApprovalRequestContent>(Assert.Single(response.Messages.Single().Contents));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithDefaultAgentMiddleware_ByDefault_InjectsDecorator()
|
||||
{
|
||||
// Arrange
|
||||
var innerClient = new Mock<IChatClient>().Object;
|
||||
var options = new ChatClientAgentOptions();
|
||||
|
||||
// Act
|
||||
var pipeline = innerClient.WithDefaultAgentMiddleware(options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(pipeline.GetService<ApprovalNotRequiredFunctionBypassingChatClient>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithDefaultAgentMiddleware_DisableApprovalNotRequiredFunctionBypassing_DoesNotInjectDecorator()
|
||||
{
|
||||
// Arrange
|
||||
var innerClient = new Mock<IChatClient>().Object;
|
||||
var options = new ChatClientAgentOptions { DisableApprovalNotRequiredFunctionBypassing = true };
|
||||
|
||||
// Act
|
||||
var pipeline = innerClient.WithDefaultAgentMiddleware(options);
|
||||
|
||||
// Assert
|
||||
Assert.Null(pipeline.GetService<ApprovalNotRequiredFunctionBypassingChatClient>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static async Task<ChatResponse> RunWithAgentContextAsync(
|
||||
ApprovalNotRequiredFunctionBypassingChatClient decorator,
|
||||
AgentSession? session,
|
||||
ChatOptions? options = null)
|
||||
{
|
||||
ChatResponse? capturedResponse = null;
|
||||
|
||||
var agent = new TestAIAgent
|
||||
{
|
||||
RunAsyncFunc = async (messages, agentSession, agentOptions, ct) =>
|
||||
{
|
||||
capturedResponse = await decorator.GetResponseAsync(messages, options, ct);
|
||||
return new AgentResponse(capturedResponse);
|
||||
}
|
||||
};
|
||||
|
||||
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], session);
|
||||
return capturedResponse!;
|
||||
}
|
||||
|
||||
private static Task<ChatResponse> RunWithAgentContextAsync(
|
||||
ApprovalNotRequiredFunctionBypassingChatClient decorator,
|
||||
AgentSession session)
|
||||
=> RunWithAgentContextAsync(decorator, session, options: null);
|
||||
|
||||
private static async Task RunStreamingWithAgentContextAsync(
|
||||
ApprovalNotRequiredFunctionBypassingChatClient decorator,
|
||||
AgentSession session,
|
||||
List<ChatResponseUpdate> updates,
|
||||
ChatOptions? options = null)
|
||||
{
|
||||
var agent = new TestAIAgent
|
||||
{
|
||||
RunAsyncFunc = async (messages, agentSession, agentOptions, ct) =>
|
||||
{
|
||||
await foreach (var update in decorator.GetStreamingResponseAsync(messages, options, ct))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "done")]);
|
||||
}
|
||||
};
|
||||
|
||||
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], session);
|
||||
}
|
||||
|
||||
private static IChatClient CreateMockChatClient(
|
||||
Func<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken, Task<ChatResponse>> onGetResponse)
|
||||
{
|
||||
var mock = new Mock<IChatClient>();
|
||||
mock.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns((IEnumerable<ChatMessage> m, ChatOptions? o, CancellationToken ct) => onGetResponse(m, o, ct));
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
private static IChatClient CreateMockStreamingChatClient(
|
||||
Func<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken, IAsyncEnumerable<ChatResponseUpdate>> onGetStreamingResponse)
|
||||
{
|
||||
var mock = new Mock<IChatClient>();
|
||||
mock.Setup(c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns((IEnumerable<ChatMessage> m, ChatOptions? o, CancellationToken ct) => onGetStreamingResponse(m, o, ct));
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<ChatResponseUpdate> ToAsyncEnumerableAsync(params ChatResponseUpdate[] updates)
|
||||
{
|
||||
foreach (var update in updates)
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
public class ChatClientAgentContinuationTokenTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToBytes_Roundtrip()
|
||||
{
|
||||
// Arrange
|
||||
ResponseContinuationToken originalToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4, 5 });
|
||||
|
||||
ChatClientAgentContinuationToken chatClientToken = new(originalToken)
|
||||
{
|
||||
InputMessages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello!"),
|
||||
new ChatMessage(ChatRole.User, "How are you?")
|
||||
],
|
||||
ResponseUpdates =
|
||||
[
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "I'm fine, thank you."),
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "How can I assist you today?")
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
ReadOnlyMemory<byte> bytes = chatClientToken.ToBytes();
|
||||
|
||||
ChatClientAgentContinuationToken tokenFromBytes = ChatClientAgentContinuationToken.FromToken(ResponseContinuationToken.FromBytes(bytes));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(tokenFromBytes);
|
||||
Assert.Equal(chatClientToken.ToBytes().ToArray(), tokenFromBytes.ToBytes().ToArray());
|
||||
|
||||
// Verify InnerToken
|
||||
Assert.Equal(chatClientToken.InnerToken.ToBytes().ToArray(), tokenFromBytes.InnerToken.ToBytes().ToArray());
|
||||
|
||||
// Verify InputMessages
|
||||
Assert.NotNull(tokenFromBytes.InputMessages);
|
||||
Assert.Equal(chatClientToken.InputMessages.Count(), tokenFromBytes.InputMessages.Count());
|
||||
for (int i = 0; i < chatClientToken.InputMessages.Count(); i++)
|
||||
{
|
||||
Assert.Equal(chatClientToken.InputMessages.ElementAt(i).Role, tokenFromBytes.InputMessages.ElementAt(i).Role);
|
||||
Assert.Equal(chatClientToken.InputMessages.ElementAt(i).Text, tokenFromBytes.InputMessages.ElementAt(i).Text);
|
||||
}
|
||||
|
||||
// Verify ResponseUpdates
|
||||
Assert.NotNull(tokenFromBytes.ResponseUpdates);
|
||||
Assert.Equal(chatClientToken.ResponseUpdates.Count, tokenFromBytes.ResponseUpdates.Count);
|
||||
for (int i = 0; i < chatClientToken.ResponseUpdates.Count; i++)
|
||||
{
|
||||
Assert.Equal(chatClientToken.ResponseUpdates.ElementAt(i).Role, tokenFromBytes.ResponseUpdates.ElementAt(i).Role);
|
||||
Assert.Equal(chatClientToken.ResponseUpdates.ElementAt(i).Text, tokenFromBytes.ResponseUpdates.ElementAt(i).Text);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialization_Roundtrip()
|
||||
{
|
||||
// Arrange
|
||||
ResponseContinuationToken originalToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4, 5 });
|
||||
|
||||
ChatClientAgentContinuationToken chatClientToken = new(originalToken)
|
||||
{
|
||||
InputMessages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello!"),
|
||||
new ChatMessage(ChatRole.User, "How are you?")
|
||||
],
|
||||
ResponseUpdates =
|
||||
[
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "I'm fine, thank you."),
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "How can I assist you today?")
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
string json = JsonSerializer.Serialize(chatClientToken, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
|
||||
|
||||
ResponseContinuationToken? deserializedToken = (ResponseContinuationToken?)JsonSerializer.Deserialize(json, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
|
||||
|
||||
ChatClientAgentContinuationToken deserializedChatClientToken = ChatClientAgentContinuationToken.FromToken(deserializedToken!);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(deserializedChatClientToken);
|
||||
Assert.Equal(chatClientToken.ToBytes().ToArray(), deserializedChatClientToken.ToBytes().ToArray());
|
||||
|
||||
// Verify InnerToken
|
||||
Assert.Equal(chatClientToken.InnerToken.ToBytes().ToArray(), deserializedChatClientToken.InnerToken.ToBytes().ToArray());
|
||||
|
||||
// Verify InputMessages
|
||||
Assert.NotNull(deserializedChatClientToken.InputMessages);
|
||||
Assert.Equal(chatClientToken.InputMessages.Count(), deserializedChatClientToken.InputMessages.Count());
|
||||
for (int i = 0; i < chatClientToken.InputMessages.Count(); i++)
|
||||
{
|
||||
Assert.Equal(chatClientToken.InputMessages.ElementAt(i).Role, deserializedChatClientToken.InputMessages.ElementAt(i).Role);
|
||||
Assert.Equal(chatClientToken.InputMessages.ElementAt(i).Text, deserializedChatClientToken.InputMessages.ElementAt(i).Text);
|
||||
}
|
||||
|
||||
// Verify ResponseUpdates
|
||||
Assert.NotNull(deserializedChatClientToken.ResponseUpdates);
|
||||
Assert.Equal(chatClientToken.ResponseUpdates.Count, deserializedChatClientToken.ResponseUpdates.Count);
|
||||
for (int i = 0; i < chatClientToken.ResponseUpdates.Count; i++)
|
||||
{
|
||||
Assert.Equal(chatClientToken.ResponseUpdates.ElementAt(i).Role, deserializedChatClientToken.ResponseUpdates.ElementAt(i).Role);
|
||||
Assert.Equal(chatClientToken.ResponseUpdates.ElementAt(i).Text, deserializedChatClientToken.ResponseUpdates.ElementAt(i).Text);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromToken_WithChatClientAgentContinuationToken_ReturnsSameInstance()
|
||||
{
|
||||
// Arrange
|
||||
ChatClientAgentContinuationToken originalToken = new(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4, 5 }));
|
||||
|
||||
// Act
|
||||
ChatClientAgentContinuationToken fromToken = ChatClientAgentContinuationToken.FromToken(originalToken);
|
||||
|
||||
// Assert
|
||||
Assert.Same(originalToken, fromToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="ChatClientAgentOptions"/> class.
|
||||
/// </summary>
|
||||
public class ChatClientAgentOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultConstructor_InitializesWithNullValues()
|
||||
{
|
||||
// Act
|
||||
var options = new ChatClientAgentOptions();
|
||||
|
||||
// Assert
|
||||
Assert.Null(options.Name);
|
||||
Assert.Null(options.Description);
|
||||
Assert.Null(options.ChatOptions);
|
||||
Assert.Null(options.ChatHistoryProvider);
|
||||
Assert.Null(options.AIContextProviders);
|
||||
Assert.False(options.UseProvidedChatClientAsIs);
|
||||
Assert.True(options.ClearOnChatHistoryProviderConflict);
|
||||
Assert.True(options.WarnOnChatHistoryProviderConflict);
|
||||
Assert.True(options.ThrowOnChatHistoryProviderConflict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithNullValues_SetsPropertiesCorrectly()
|
||||
{
|
||||
// Act
|
||||
var options = new ChatClientAgentOptions() { Name = null, Description = null, ChatOptions = new() { Tools = null, Instructions = null } };
|
||||
|
||||
// Assert
|
||||
Assert.Null(options.Name);
|
||||
Assert.Null(options.Description);
|
||||
Assert.Null(options.AIContextProviders);
|
||||
Assert.Null(options.ChatHistoryProvider);
|
||||
Assert.NotNull(options.ChatOptions);
|
||||
Assert.Null(options.ChatOptions.Instructions);
|
||||
Assert.Null(options.ChatOptions.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithToolsOnly_SetsChatOptionsWithTools()
|
||||
{
|
||||
// Arrange
|
||||
var tools = new List<AITool> { AIFunctionFactory.Create(() => "test") };
|
||||
|
||||
// Act
|
||||
var options = new ChatClientAgentOptions()
|
||||
{
|
||||
Name = null,
|
||||
Description = null,
|
||||
ChatOptions = new() { Tools = tools }
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Null(options.Name);
|
||||
Assert.Null(options.Description);
|
||||
Assert.NotNull(options.ChatOptions);
|
||||
AssertSameTools(tools, options.ChatOptions.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithAllParameters_SetsAllPropertiesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
const string Instructions = "Test instructions";
|
||||
const string Name = "Test name";
|
||||
const string Description = "Test description";
|
||||
var tools = new List<AITool> { AIFunctionFactory.Create(() => "test") };
|
||||
|
||||
// Act
|
||||
var options = new ChatClientAgentOptions()
|
||||
{
|
||||
Name = Name,
|
||||
Description = Description,
|
||||
ChatOptions = new() { Tools = tools, Instructions = Instructions }
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal(Name, options.Name);
|
||||
Assert.Equal(Instructions, options.ChatOptions.Instructions);
|
||||
Assert.Equal(Description, options.Description);
|
||||
Assert.NotNull(options.ChatOptions);
|
||||
AssertSameTools(tools, options.ChatOptions.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithNameAndDescriptionOnly_DoesNotCreateChatOptions()
|
||||
{
|
||||
// Arrange
|
||||
const string Name = "Test name";
|
||||
const string Description = "Test description";
|
||||
|
||||
// Act
|
||||
var options = new ChatClientAgentOptions()
|
||||
{
|
||||
Name = Name,
|
||||
Description = Description,
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal(Name, options.Name);
|
||||
Assert.Equal(Description, options.Description);
|
||||
Assert.Null(options.ChatOptions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clone_CreatesDeepCopyWithSameValues()
|
||||
{
|
||||
// Arrange
|
||||
const string Name = "Test name";
|
||||
const string Description = "Test description";
|
||||
var tools = new List<AITool> { AIFunctionFactory.Create(() => "test") };
|
||||
|
||||
var mockChatHistoryProvider = new Mock<ChatHistoryProvider>(null, null, null).Object;
|
||||
var mockAIContextProvider = new Mock<AIContextProvider>(null, null, null).Object;
|
||||
|
||||
var original = new ChatClientAgentOptions()
|
||||
{
|
||||
Name = Name,
|
||||
Description = Description,
|
||||
ChatOptions = new() { Tools = tools },
|
||||
Id = "test-id",
|
||||
ChatHistoryProvider = mockChatHistoryProvider,
|
||||
AIContextProviders = [mockAIContextProvider],
|
||||
UseProvidedChatClientAsIs = true,
|
||||
ClearOnChatHistoryProviderConflict = false,
|
||||
WarnOnChatHistoryProviderConflict = false,
|
||||
ThrowOnChatHistoryProviderConflict = false,
|
||||
DisableApprovalNotRequiredFunctionBypassing = true,
|
||||
};
|
||||
|
||||
// Act
|
||||
var clone = original.Clone();
|
||||
|
||||
// Assert
|
||||
Assert.NotSame(original, clone);
|
||||
Assert.Equal(original.Id, clone.Id);
|
||||
Assert.Equal(original.Name, clone.Name);
|
||||
Assert.Equal(original.Description, clone.Description);
|
||||
Assert.Same(original.ChatHistoryProvider, clone.ChatHistoryProvider);
|
||||
Assert.Equal(original.AIContextProviders, clone.AIContextProviders);
|
||||
Assert.Equal(original.UseProvidedChatClientAsIs, clone.UseProvidedChatClientAsIs);
|
||||
Assert.Equal(original.ClearOnChatHistoryProviderConflict, clone.ClearOnChatHistoryProviderConflict);
|
||||
Assert.Equal(original.WarnOnChatHistoryProviderConflict, clone.WarnOnChatHistoryProviderConflict);
|
||||
Assert.Equal(original.ThrowOnChatHistoryProviderConflict, clone.ThrowOnChatHistoryProviderConflict);
|
||||
Assert.Equal(original.DisableApprovalNotRequiredFunctionBypassing, clone.DisableApprovalNotRequiredFunctionBypassing);
|
||||
|
||||
// ChatOptions should be cloned, not the same reference
|
||||
Assert.NotSame(original.ChatOptions, clone.ChatOptions);
|
||||
Assert.Equal(original.ChatOptions?.Instructions, clone.ChatOptions?.Instructions);
|
||||
Assert.Equal(original.ChatOptions?.Tools, clone.ChatOptions?.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clone_WithoutProvidingChatOptions_ClonesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var mockChatHistoryProvider = new Mock<ChatHistoryProvider>(null, null, null).Object;
|
||||
var mockAIContextProvider = new Mock<AIContextProvider>(null, null, null).Object;
|
||||
|
||||
var original = new ChatClientAgentOptions
|
||||
{
|
||||
Id = "test-id",
|
||||
Name = "Test name",
|
||||
Description = "Test description",
|
||||
ChatHistoryProvider = mockChatHistoryProvider,
|
||||
AIContextProviders = [mockAIContextProvider]
|
||||
};
|
||||
|
||||
// Act
|
||||
var clone = original.Clone();
|
||||
|
||||
// Assert
|
||||
Assert.NotSame(original, clone);
|
||||
Assert.Equal(original.Id, clone.Id);
|
||||
Assert.Equal(original.Name, clone.Name);
|
||||
Assert.Equal(original.Description, clone.Description);
|
||||
Assert.Null(original.ChatOptions);
|
||||
Assert.Same(original.ChatHistoryProvider, clone.ChatHistoryProvider);
|
||||
Assert.Equal(original.AIContextProviders, clone.AIContextProviders);
|
||||
}
|
||||
|
||||
private static void AssertSameTools(IList<AITool>? expected, IList<AITool>? actual)
|
||||
{
|
||||
var index = 0;
|
||||
foreach (var tool in expected ?? [])
|
||||
{
|
||||
Assert.Same(tool, actual?[index]);
|
||||
index++;
|
||||
}
|
||||
}
|
||||
}
|
||||
+422
@@ -0,0 +1,422 @@
|
||||
// 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 Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
public class ChatClientAgentRunOptionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify that ChatClientAgentRunOptions constructor works with null chatOptions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConstructorWorksWithNullChatOptions()
|
||||
{
|
||||
// Act
|
||||
var runOptions = new ChatClientAgentRunOptions();
|
||||
|
||||
// Assert
|
||||
Assert.Null(runOptions.ChatOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that ChatClientAgentRunOptions ChatOptions property is set and mutable.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ChatOptionsPropertyIsReadOnly()
|
||||
{
|
||||
// Arrange
|
||||
var chatOptions = new ChatOptions { MaxOutputTokens = 100 };
|
||||
var runOptions = new ChatClientAgentRunOptions(chatOptions);
|
||||
chatOptions.MaxOutputTokens = 200; // Change the property to verify mutability
|
||||
|
||||
// Act & Assert
|
||||
Assert.Same(chatOptions, runOptions.ChatOptions);
|
||||
|
||||
// Verify that the property doesn't have a setter by checking if it's the same instance
|
||||
var retrievedOptions = runOptions.ChatOptions!;
|
||||
Assert.Same(chatOptions, retrievedOptions);
|
||||
Assert.Equal(200, retrievedOptions.MaxOutputTokens); // Ensure the change is reflected
|
||||
}
|
||||
|
||||
#region ChatClientFactory Tests
|
||||
|
||||
/// <summary>
|
||||
/// Tests that ChatClientFactory is called and transforms the client for RunAsync.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_WithChatClientFactory_UsesTransformedClientAsync()
|
||||
{
|
||||
// Arrange
|
||||
var originalClient = new Mock<IChatClient>();
|
||||
var transformedClient = new Mock<IChatClient>();
|
||||
var factoryCallCount = 0;
|
||||
|
||||
// Setup the original client to throw if called (should not be used)
|
||||
originalClient.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Throws(new InvalidOperationException("Original client should not be called"));
|
||||
|
||||
// Setup the transformed client to return a response
|
||||
transformedClient.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Transformed response")]));
|
||||
|
||||
// Create the factory that transforms the client
|
||||
IChatClient ClientFactory(IChatClient client)
|
||||
{
|
||||
factoryCallCount++;
|
||||
Assert.Same(originalClient.Object, client); // Verify original client is passed
|
||||
return transformedClient.Object;
|
||||
}
|
||||
|
||||
var agent = new ChatClientAgent(originalClient.Object, new ChatClientAgentOptions() { UseProvidedChatClientAsIs = true });
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
|
||||
var options = new ChatClientAgentRunOptions { ChatClientFactory = ClientFactory };
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync(messages, null, options, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.Equal(1, factoryCallCount); // Factory should be called exactly once
|
||||
transformedClient.Verify(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
originalClient.Verify(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that ChatClientFactory is called and transforms the client for RunStreamingAsync.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WithChatClientFactory_UsesTransformedClientAsync()
|
||||
{
|
||||
// Arrange
|
||||
var originalClient = new Mock<IChatClient>();
|
||||
var transformedClient = new Mock<IChatClient>();
|
||||
var factoryCallCount = 0;
|
||||
|
||||
// Setup the original client to throw if called (should not be used)
|
||||
originalClient.Setup(c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Throws(new InvalidOperationException("Original client should not be called"));
|
||||
|
||||
// Setup the transformed client to return streaming responses
|
||||
var streamingResponses = new[]
|
||||
{
|
||||
new ChatResponseUpdate { Contents = [new TextContent("Streaming ")] },
|
||||
new ChatResponseUpdate { Contents = [new TextContent("response")] }
|
||||
};
|
||||
transformedClient.Setup(c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(streamingResponses.ToAsyncEnumerable());
|
||||
|
||||
// Create the factory that transforms the client
|
||||
IChatClient ClientFactory(IChatClient client)
|
||||
{
|
||||
factoryCallCount++;
|
||||
Assert.Same(originalClient.Object, client); // Verify original client is passed
|
||||
return transformedClient.Object;
|
||||
}
|
||||
|
||||
var agent = new ChatClientAgent(originalClient.Object, new ChatClientAgentOptions() { UseProvidedChatClientAsIs = true });
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
|
||||
var options = new ChatClientAgentRunOptions { ChatClientFactory = ClientFactory };
|
||||
|
||||
// Act
|
||||
var responseUpdates = new List<AgentResponseUpdate>();
|
||||
await foreach (var update in agent.RunStreamingAsync(messages, null, options, CancellationToken.None))
|
||||
{
|
||||
responseUpdates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(responseUpdates);
|
||||
Assert.Equal(1, factoryCallCount); // Factory should be called exactly once
|
||||
transformedClient.Verify(c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
originalClient.Verify(c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that without ChatClientFactory, the original client is used for RunAsync.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_WithoutChatClientFactory_UsesOriginalClientAsync()
|
||||
{
|
||||
// Arrange
|
||||
var originalClient = new Mock<IChatClient>();
|
||||
|
||||
originalClient.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Original response")]));
|
||||
|
||||
var agent = new ChatClientAgent(originalClient.Object);
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
|
||||
|
||||
// Act - No ChatClientFactory provided
|
||||
var response = await agent.RunAsync(messages, null, null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
originalClient.Verify(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that without ChatClientFactory, the original client is used for RunStreamingAsync.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WithoutChatClientFactory_UsesOriginalClientAsync()
|
||||
{
|
||||
// Arrange
|
||||
var originalClient = new Mock<IChatClient>();
|
||||
|
||||
var streamingResponses = new[]
|
||||
{
|
||||
new ChatResponseUpdate { Contents = [new TextContent("Original ")] },
|
||||
new ChatResponseUpdate { Contents = [new TextContent("streaming")] }
|
||||
};
|
||||
originalClient.Setup(c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(streamingResponses.ToAsyncEnumerable());
|
||||
|
||||
var agent = new ChatClientAgent(originalClient.Object);
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
|
||||
|
||||
// Act - No ChatClientFactory provided
|
||||
var responseUpdates = new List<AgentResponseUpdate>();
|
||||
await foreach (var update in agent.RunStreamingAsync(messages, null, null, CancellationToken.None))
|
||||
{
|
||||
responseUpdates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(responseUpdates);
|
||||
originalClient.Verify(c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that ChatClientFactory is called for each separate RunAsync call.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_MultipleCalls_ChatClientFactoryCalledEachTimeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var originalClient = new Mock<IChatClient>();
|
||||
var transformedClient = new Mock<IChatClient>();
|
||||
var factoryCallCount = 0;
|
||||
|
||||
transformedClient.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")]));
|
||||
|
||||
IChatClient ClientFactory(IChatClient client)
|
||||
{
|
||||
factoryCallCount++;
|
||||
return transformedClient.Object;
|
||||
}
|
||||
|
||||
var agent = new ChatClientAgent(originalClient.Object);
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
|
||||
var options = new ChatClientAgentRunOptions { ChatClientFactory = ClientFactory };
|
||||
|
||||
// Act - Call RunAsync multiple times
|
||||
await agent.RunAsync(messages, null, options, CancellationToken.None);
|
||||
await agent.RunAsync(messages, null, options, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, factoryCallCount); // Factory should be called for each run
|
||||
transformedClient.Verify(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that subsequent calls without ChatClientFactory use the original client.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_AfterFactoryCall_WithoutFactory_UsesOriginalClientAsync()
|
||||
{
|
||||
// Arrange
|
||||
var originalClient = new Mock<IChatClient>();
|
||||
var transformedClient = new Mock<IChatClient>();
|
||||
|
||||
originalClient.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Original response")]));
|
||||
|
||||
transformedClient.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Transformed response")]));
|
||||
|
||||
IChatClient ClientFactory(IChatClient client) => transformedClient.Object;
|
||||
|
||||
var agent = new ChatClientAgent(originalClient.Object);
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
|
||||
var optionsWithFactory = new ChatClientAgentRunOptions { ChatClientFactory = ClientFactory };
|
||||
|
||||
// Act - First call with factory, second call without
|
||||
await agent.RunAsync(messages, null, optionsWithFactory, CancellationToken.None);
|
||||
await agent.RunAsync(messages, null, null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
transformedClient.Verify(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
originalClient.Verify(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that ChatClientFactory returning null throws an exception.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_ChatClientFactoryReturnsNull_ThrowsExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var originalClient = new Mock<IChatClient>();
|
||||
|
||||
static IChatClient ClientFactory(IChatClient client) => null!;
|
||||
|
||||
var agent = new ChatClientAgent(originalClient.Object);
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
|
||||
var options = new ChatClientAgentRunOptions { ChatClientFactory = ClientFactory };
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(async () =>
|
||||
await agent.RunAsync(messages, null, options, CancellationToken.None));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Clone Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that Clone returns a new instance with the same property values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CloneReturnsNewInstanceWithSameValues()
|
||||
{
|
||||
// Arrange
|
||||
var chatOptions = new ChatOptions { MaxOutputTokens = 100, Temperature = 0.7f };
|
||||
Func<IChatClient, IChatClient> factory = c => c;
|
||||
var runOptions = new ChatClientAgentRunOptions(chatOptions)
|
||||
{
|
||||
ChatClientFactory = factory,
|
||||
AllowBackgroundResponses = true,
|
||||
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }),
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary
|
||||
{
|
||||
["key1"] = "value1"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentRunOptions cloneAsBase = runOptions.Clone();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(cloneAsBase);
|
||||
Assert.IsType<ChatClientAgentRunOptions>(cloneAsBase);
|
||||
ChatClientAgentRunOptions clone = (ChatClientAgentRunOptions)cloneAsBase;
|
||||
Assert.NotSame(runOptions, clone);
|
||||
Assert.NotNull(clone.ChatOptions);
|
||||
Assert.NotSame(runOptions.ChatOptions, clone.ChatOptions);
|
||||
Assert.Equal(100, clone.ChatOptions!.MaxOutputTokens);
|
||||
Assert.Equal(0.7f, clone.ChatOptions.Temperature);
|
||||
Assert.Same(factory, clone.ChatClientFactory);
|
||||
Assert.Equal(runOptions.AllowBackgroundResponses, clone.AllowBackgroundResponses);
|
||||
Assert.Same(runOptions.ContinuationToken, clone.ContinuationToken);
|
||||
Assert.NotNull(clone.AdditionalProperties);
|
||||
Assert.NotSame(runOptions.AdditionalProperties, clone.AdditionalProperties);
|
||||
Assert.Equal("value1", clone.AdditionalProperties["key1"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that modifying the cloned ChatOptions does not affect the original.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CloneCreatesIndependentChatOptions()
|
||||
{
|
||||
// Arrange
|
||||
var chatOptions = new ChatOptions { MaxOutputTokens = 100 };
|
||||
var runOptions = new ChatClientAgentRunOptions(chatOptions);
|
||||
|
||||
// Act
|
||||
ChatClientAgentRunOptions clone = (ChatClientAgentRunOptions)runOptions.Clone();
|
||||
clone.ChatOptions!.MaxOutputTokens = 200;
|
||||
|
||||
// Assert
|
||||
Assert.Equal(100, runOptions.ChatOptions!.MaxOutputTokens);
|
||||
Assert.Equal(200, clone.ChatOptions.MaxOutputTokens);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that modifying the cloned AdditionalProperties does not affect the original.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CloneCreatesIndependentAdditionalPropertiesDictionary()
|
||||
{
|
||||
// Arrange
|
||||
var runOptions = new ChatClientAgentRunOptions
|
||||
{
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary
|
||||
{
|
||||
["key1"] = "value1"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
ChatClientAgentRunOptions clone = (ChatClientAgentRunOptions)runOptions.Clone();
|
||||
clone.AdditionalProperties!["key2"] = "value2";
|
||||
|
||||
// Assert
|
||||
Assert.True(clone.AdditionalProperties.ContainsKey("key2"));
|
||||
Assert.False(runOptions.AdditionalProperties.ContainsKey("key2"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
#pragma warning disable CA1861 // Avoid constant arrays as arguments
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
public class ChatClientAgentSessionTests
|
||||
{
|
||||
#region Constructor and Property Tests
|
||||
|
||||
[Fact]
|
||||
public void ConstructorSetsDefaults()
|
||||
{
|
||||
// Arrange & Act
|
||||
var session = new ChatClientAgentSession();
|
||||
|
||||
// Assert
|
||||
Assert.Null(session.ConversationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetConversationIdRoundtrips()
|
||||
{
|
||||
// Arrange
|
||||
var session = new ChatClientAgentSession();
|
||||
const string ConversationId = "test-session-id";
|
||||
|
||||
// Act
|
||||
session.ConversationId = ConversationId;
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ConversationId, session.ConversationId);
|
||||
}
|
||||
|
||||
#endregion Constructor and Property Tests
|
||||
|
||||
#region Deserialize Tests
|
||||
|
||||
[Fact]
|
||||
public void VerifyDeserializeWithMessages()
|
||||
{
|
||||
// Arrange
|
||||
var json = JsonSerializer.Deserialize("""
|
||||
{
|
||||
"stateBag": {
|
||||
"InMemoryChatHistoryProvider": {
|
||||
"messages": [{"authorName": "testAuthor"}]
|
||||
}
|
||||
}
|
||||
}
|
||||
""", TestJsonSerializerContext.Default.JsonElement);
|
||||
|
||||
// Act.
|
||||
var session = ChatClientAgentSession.Deserialize(json, TestJsonSerializerContext.Default.Options);
|
||||
|
||||
// Assert
|
||||
Assert.Null(session.ConversationId);
|
||||
|
||||
var chatHistoryProvider = new InMemoryChatHistoryProvider();
|
||||
var messages = chatHistoryProvider.GetMessages(session);
|
||||
Assert.Single(messages);
|
||||
Assert.Equal("testAuthor", messages[0].AuthorName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyDeserializeWithId()
|
||||
{
|
||||
// Arrange
|
||||
var json = JsonSerializer.Deserialize("""
|
||||
{
|
||||
"conversationId": "TestConvId"
|
||||
}
|
||||
""", TestJsonSerializerContext.Default.JsonElement);
|
||||
|
||||
// Act
|
||||
var session = ChatClientAgentSession.Deserialize(json);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("TestConvId", session.ConversationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyDeserializeWithStateBag()
|
||||
{
|
||||
// Arrange
|
||||
var json = JsonSerializer.Deserialize("""
|
||||
{
|
||||
"conversationId": "TestConvId",
|
||||
"stateBag": {
|
||||
"dog": {
|
||||
"name": "Fido"
|
||||
}
|
||||
}
|
||||
}
|
||||
""", TestJsonSerializerContext.Default.JsonElement);
|
||||
// Act
|
||||
var session = ChatClientAgentSession.Deserialize(json);
|
||||
|
||||
// Assert
|
||||
var dog = session.StateBag.GetValue<Animal>("dog", TestJsonSerializerContext.Default.Options);
|
||||
Assert.NotNull(dog);
|
||||
Assert.Equal("Fido", dog.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeserializeWithInvalidJsonThrows()
|
||||
{
|
||||
// Arrange
|
||||
var invalidJson = JsonSerializer.Deserialize("[42]", TestJsonSerializerContext.Default.JsonElement);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => ChatClientAgentSession.Deserialize(invalidJson));
|
||||
}
|
||||
|
||||
#endregion Deserialize Tests
|
||||
|
||||
#region Serialize Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify session serialization to JSON when the session has an id.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void VerifySessionSerializationWithId()
|
||||
{
|
||||
// Arrange
|
||||
var session = new ChatClientAgentSession { ConversationId = "TestConvId" };
|
||||
|
||||
// Act
|
||||
var json = session.Serialize();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(JsonValueKind.Object, json.ValueKind);
|
||||
|
||||
Assert.True(json.TryGetProperty("conversationId", out var idProperty));
|
||||
Assert.Equal("TestConvId", idProperty.GetString());
|
||||
|
||||
Assert.False(json.TryGetProperty("chatHistoryProviderState", out _));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify session serialization to JSON when the session has messages.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void VerifySessionSerializationWithMessages()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new InMemoryChatHistoryProvider();
|
||||
var session = new ChatClientAgentSession();
|
||||
provider.SetMessages(session, [new(ChatRole.User, "TestContent") { AuthorName = "TestAuthor" }]);
|
||||
|
||||
// Act
|
||||
var json = session.Serialize();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(JsonValueKind.Object, json.ValueKind);
|
||||
|
||||
Assert.False(json.TryGetProperty("conversationId", out _));
|
||||
|
||||
// Messages should be stored in the stateBag
|
||||
Assert.True(json.TryGetProperty("stateBag", out var stateBagProperty));
|
||||
Assert.Equal(JsonValueKind.Object, stateBagProperty.ValueKind);
|
||||
Assert.True(stateBagProperty.TryGetProperty("InMemoryChatHistoryProvider", out var providerStateProperty));
|
||||
Assert.Equal(JsonValueKind.Object, providerStateProperty.ValueKind);
|
||||
Assert.True(providerStateProperty.TryGetProperty("messages", out var messagesProperty));
|
||||
Assert.Equal(JsonValueKind.Array, messagesProperty.ValueKind);
|
||||
Assert.Single(messagesProperty.EnumerateArray());
|
||||
|
||||
var message = messagesProperty.EnumerateArray().First();
|
||||
Assert.Equal("TestAuthor", message.GetProperty("authorName").GetString());
|
||||
Assert.True(message.TryGetProperty("contents", out var contentsProperty));
|
||||
Assert.Equal(JsonValueKind.Array, contentsProperty.ValueKind);
|
||||
Assert.Single(contentsProperty.EnumerateArray());
|
||||
|
||||
var textContent = contentsProperty.EnumerateArray().First();
|
||||
Assert.Equal("TestContent", textContent.GetProperty("text").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifySessionSerializationWithWithStateBag()
|
||||
{
|
||||
// Arrange
|
||||
var session = new ChatClientAgentSession();
|
||||
session.StateBag.SetValue("dog", new Animal { Name = "Fido" }, TestJsonSerializerContext.Default.Options);
|
||||
|
||||
// Act
|
||||
var json = session.Serialize();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(JsonValueKind.Object, json.ValueKind);
|
||||
Assert.True(json.TryGetProperty("stateBag", out var stateBagProperty));
|
||||
Assert.Equal(JsonValueKind.Object, stateBagProperty.ValueKind);
|
||||
Assert.True(stateBagProperty.TryGetProperty("dog", out var dogProperty));
|
||||
Assert.Equal(JsonValueKind.Object, dogProperty.ValueKind);
|
||||
Assert.True(dogProperty.TryGetProperty("name", out var nameProperty));
|
||||
Assert.Equal("Fido", nameProperty.GetString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify session serialization to JSON with custom options.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void VerifySessionSerializationWithCustomOptions()
|
||||
{
|
||||
// Arrange
|
||||
var session = new ChatClientAgentSession();
|
||||
JsonSerializerOptions options = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
|
||||
options.TypeInfoResolverChain.Add(AgentJsonUtilities.DefaultOptions.TypeInfoResolver!);
|
||||
|
||||
// Act
|
||||
var json = session.Serialize(options);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(JsonValueKind.Object, json.ValueKind);
|
||||
|
||||
// [JsonPropertyName] takes precedence over naming policy
|
||||
Assert.True(json.TryGetProperty("conversationId", out var _));
|
||||
}
|
||||
|
||||
#endregion Serialize Tests
|
||||
|
||||
#region StateBag Roundtrip Tests
|
||||
|
||||
[Fact]
|
||||
public void VerifyStateBagRoundtrips()
|
||||
{
|
||||
// Arrange
|
||||
var session = new ChatClientAgentSession();
|
||||
session.StateBag.SetValue("dog", new Animal { Name = "Fido" }, TestJsonSerializerContext.Default.Options);
|
||||
|
||||
// Act
|
||||
var serializedSession = session.Serialize();
|
||||
var deserializedSession = ChatClientAgentSession.Deserialize(serializedSession);
|
||||
|
||||
// Assert
|
||||
var dog = deserializedSession.StateBag.GetValue<Animal>("dog", TestJsonSerializerContext.Default.Options);
|
||||
Assert.NotNull(dog);
|
||||
Assert.Equal("Fido", dog.Name);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
internal sealed class Animal
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -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="PerServiceCallChatHistoryPersistingChatClient"/> 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))}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+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="PerServiceCallChatHistoryPersistingChatClient"/>,
|
||||
/// 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] },
|
||||
RequirePerServiceCallChatHistoryPersistence = true,
|
||||
},
|
||||
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] },
|
||||
},
|
||||
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
|
||||
// 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"),
|
||||
]);
|
||||
}
|
||||
|
||||
#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] },
|
||||
},
|
||||
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] },
|
||||
RequirePerServiceCallChatHistoryPersistence = true,
|
||||
},
|
||||
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
|
||||
}
|
||||
+833
@@ -0,0 +1,833 @@
|
||||
// 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 Moq;
|
||||
using Moq.Protected;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains unit tests for ChatClientAgent background responses functionality.
|
||||
/// </summary>
|
||||
public class ChatClientAgent_BackgroundResponsesTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task RunAsync_PropagatesBackgroundResponsesPropertiesToChatClientAsync(bool providePropsViaChatOptions)
|
||||
{
|
||||
// Arrange
|
||||
var continuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }));
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient
|
||||
.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((m, co, ct) => capturedChatOptions = co)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ContinuationToken = null, ConversationId = "conversation-id" });
|
||||
|
||||
AgentRunOptions agentRunOptions;
|
||||
|
||||
if (providePropsViaChatOptions)
|
||||
{
|
||||
ChatOptions chatOptions = new()
|
||||
{
|
||||
AllowBackgroundResponses = true,
|
||||
ContinuationToken = continuationToken
|
||||
};
|
||||
|
||||
agentRunOptions = new ChatClientAgentRunOptions(chatOptions);
|
||||
}
|
||||
else
|
||||
{
|
||||
agentRunOptions = new AgentRunOptions()
|
||||
{
|
||||
AllowBackgroundResponses = true,
|
||||
ContinuationToken = continuationToken
|
||||
};
|
||||
}
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
|
||||
ChatClientAgentSession? session = new() { ConversationId = "conversation-id" };
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(session, options: agentRunOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.True(capturedChatOptions.AllowBackgroundResponses);
|
||||
Assert.Same(continuationToken.InnerToken, capturedChatOptions.ContinuationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WhenPropertiesSetInBothLocations_PrioritizesAgentRunOptionsOverChatOptionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var continuationToken1 = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }));
|
||||
var continuationToken2 = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }));
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient
|
||||
.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((m, co, ct) => capturedChatOptions = co)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ContinuationToken = null, ConversationId = "conversation-id" });
|
||||
|
||||
ChatOptions chatOptions = new()
|
||||
{
|
||||
AllowBackgroundResponses = true,
|
||||
ContinuationToken = continuationToken1
|
||||
};
|
||||
|
||||
ChatClientAgentRunOptions agentRunOptions = new(chatOptions)
|
||||
{
|
||||
AllowBackgroundResponses = false,
|
||||
ContinuationToken = continuationToken2
|
||||
};
|
||||
|
||||
ChatClientAgentSession? session = new() { ConversationId = "conversation-id" };
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(session, options: agentRunOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.False(capturedChatOptions.AllowBackgroundResponses);
|
||||
Assert.Same(continuationToken2.InnerToken, capturedChatOptions.ContinuationToken);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task RunStreamingAsync_PropagatesBackgroundResponsesPropertiesToChatClientAsync(bool providePropsViaChatOptions)
|
||||
{
|
||||
// Arrange
|
||||
ChatResponseUpdate[] returnUpdates =
|
||||
[
|
||||
new ChatResponseUpdate(role: ChatRole.Assistant, content: "wh") { ConversationId = "conversation-id" },
|
||||
new ChatResponseUpdate(role: ChatRole.Assistant, content: "at?") { ConversationId = "conversation-id" },
|
||||
];
|
||||
|
||||
var continuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) { InputMessages = [new ChatMessage()] };
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient
|
||||
.Setup(c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((m, co, ct) => capturedChatOptions = co)
|
||||
.Returns(ToAsyncEnumerableAsync(returnUpdates));
|
||||
|
||||
AgentRunOptions agentRunOptions;
|
||||
|
||||
if (providePropsViaChatOptions)
|
||||
{
|
||||
ChatOptions chatOptions = new()
|
||||
{
|
||||
AllowBackgroundResponses = true,
|
||||
ContinuationToken = continuationToken
|
||||
};
|
||||
|
||||
agentRunOptions = new ChatClientAgentRunOptions(chatOptions);
|
||||
}
|
||||
else
|
||||
{
|
||||
agentRunOptions = new AgentRunOptions()
|
||||
{
|
||||
AllowBackgroundResponses = true,
|
||||
ContinuationToken = continuationToken
|
||||
};
|
||||
}
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
|
||||
ChatClientAgentSession? session = new() { ConversationId = "conversation-id" };
|
||||
|
||||
// Act
|
||||
await foreach (var _ in agent.RunStreamingAsync(session, options: agentRunOptions))
|
||||
{
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
|
||||
Assert.True(capturedChatOptions.AllowBackgroundResponses);
|
||||
Assert.Same(continuationToken.InnerToken, capturedChatOptions.ContinuationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WhenPropertiesSetInBothLocations_PrioritizesAgentRunOptionsOverChatOptionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatResponseUpdate[] returnUpdates =
|
||||
[
|
||||
new ChatResponseUpdate(role: ChatRole.Assistant, content: "wh") { ConversationId = "conversation-id" },
|
||||
];
|
||||
|
||||
var continuationToken1 = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) { InputMessages = [new ChatMessage()] };
|
||||
var continuationToken2 = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) { InputMessages = [new ChatMessage()] };
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient
|
||||
.Setup(c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((m, co, ct) => capturedChatOptions = co)
|
||||
.Returns(ToAsyncEnumerableAsync(returnUpdates));
|
||||
|
||||
ChatOptions chatOptions = new()
|
||||
{
|
||||
AllowBackgroundResponses = true,
|
||||
ContinuationToken = continuationToken1
|
||||
};
|
||||
|
||||
ChatClientAgentRunOptions agentRunOptions = new(chatOptions)
|
||||
{
|
||||
AllowBackgroundResponses = false,
|
||||
ContinuationToken = continuationToken2
|
||||
};
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
|
||||
var session = new ChatClientAgentSession() { ConversationId = "conversation-id" };
|
||||
|
||||
// Act
|
||||
await foreach (var _ in agent.RunStreamingAsync(session, options: agentRunOptions))
|
||||
{
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.False(capturedChatOptions.AllowBackgroundResponses);
|
||||
Assert.Same(continuationToken2.InnerToken, capturedChatOptions.ContinuationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WhenContinuationTokenReceivedFromChatResponse_WrapsContinuationTokenAsync()
|
||||
{
|
||||
// Arrange
|
||||
var continuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient
|
||||
.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "partial")]) { ContinuationToken = continuationToken });
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
var runOptions = new ChatClientAgentRunOptions(new ChatOptions { AllowBackgroundResponses = true });
|
||||
|
||||
ChatClientAgentSession? session = new();
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync([new(ChatRole.User, "hi")], session, options: runOptions);
|
||||
|
||||
// Assert
|
||||
Assert.Same(continuationToken, (response.ContinuationToken as ChatClientAgentContinuationToken)?.InnerToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WhenContinuationTokenReceived_WrapsContinuationTokenAsync()
|
||||
{
|
||||
// Arrange
|
||||
var token1 = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
|
||||
ChatResponseUpdate[] expectedUpdates =
|
||||
[
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "pa") { ContinuationToken = token1 },
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "rt") { ContinuationToken = null } // terminal
|
||||
];
|
||||
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient
|
||||
.Setup(c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(ToAsyncEnumerableAsync(expectedUpdates));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
|
||||
ChatClientAgentSession? session = new();
|
||||
|
||||
// Act
|
||||
var actualUpdates = new List<AgentResponseUpdate>();
|
||||
await foreach (var u in agent.RunStreamingAsync([new(ChatRole.User, "hi")], session, options: new ChatClientAgentRunOptions(new ChatOptions { AllowBackgroundResponses = true })))
|
||||
{
|
||||
actualUpdates.Add(u);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, actualUpdates.Count);
|
||||
Assert.Same(token1, (actualUpdates[0].ContinuationToken as ChatClientAgentContinuationToken)?.InnerToken);
|
||||
Assert.Null(actualUpdates[1].ContinuationToken); // last update has null token
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WhenMessagesProvidedWithContinuationToken_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
|
||||
AgentRunOptions runOptions = new() { ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) };
|
||||
|
||||
IEnumerable<ChatMessage> inputMessages = [new ChatMessage(ChatRole.User, "test message")];
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync(inputMessages, options: runOptions));
|
||||
|
||||
// Verify that the IChatClient was never called due to early validation
|
||||
mockChatClient.Verify(
|
||||
c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WhenMessagesProvidedWithContinuationToken_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
|
||||
AgentRunOptions runOptions = new() { ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) };
|
||||
|
||||
IEnumerable<ChatMessage> inputMessages = [new ChatMessage(ChatRole.User, "test message")];
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
{
|
||||
await foreach (var update in agent.RunStreamingAsync(inputMessages, options: runOptions))
|
||||
{
|
||||
// Should not reach here
|
||||
}
|
||||
});
|
||||
|
||||
// Verify that the IChatClient was never called due to early validation
|
||||
mockChatClient.Verify(
|
||||
c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WhenContinuationTokenProvided_SkipsSessionMessagePopulationAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> capturedMessages = [];
|
||||
|
||||
// Create a mock chat history provider that would normally provide messages
|
||||
var mockChatHistoryProvider = new Mock<ChatHistoryProvider>(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["ChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync([new(ChatRole.User, "Message from chat history provider")]);
|
||||
|
||||
// Create a mock AI context provider that would normally provide context
|
||||
var mockContextProvider = new Mock<AIContextProvider>(null, null, null);
|
||||
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["Provider1"]);
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new AIContext
|
||||
{
|
||||
Messages = [new(ChatRole.System, "Message from AI context")],
|
||||
Instructions = "context instructions"
|
||||
});
|
||||
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient
|
||||
.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
|
||||
capturedMessages.AddRange(msgs))
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "continued response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object, options: new()
|
||||
{
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
AIContextProviders = [mockContextProvider.Object]
|
||||
});
|
||||
|
||||
// Create a session
|
||||
ChatClientAgentSession? session = new();
|
||||
|
||||
AgentRunOptions runOptions = new()
|
||||
{
|
||||
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }))
|
||||
};
|
||||
|
||||
// Act
|
||||
await agent.RunAsync([], session, options: runOptions);
|
||||
|
||||
// Assert
|
||||
|
||||
// With continuation token, session message population should be skipped
|
||||
Assert.Empty(capturedMessages);
|
||||
|
||||
// Verify that chat history provider was never called due to continuation token
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", Times.Never(), ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>());
|
||||
|
||||
// Verify that AI context provider was never called due to continuation token
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask<AIContext>>("InvokingCoreAsync", Times.Never(), ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WhenContinuationTokenProvided_SkipsSessionMessagePopulationAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> capturedMessages = [];
|
||||
|
||||
// Create a mock chat history provider that would normally provide messages
|
||||
var mockChatHistoryProvider = new Mock<ChatHistoryProvider>(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["ChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync([new(ChatRole.User, "Message from chat history provider")]);
|
||||
|
||||
// Create a mock AI context provider that would normally provide context
|
||||
var mockContextProvider = new Mock<AIContextProvider>(null, null, null);
|
||||
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["Provider1"]);
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new AIContext
|
||||
{
|
||||
Messages = [new(ChatRole.System, "Message from AI context")],
|
||||
Instructions = "context instructions"
|
||||
});
|
||||
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient
|
||||
.Setup(c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
|
||||
capturedMessages.AddRange(msgs))
|
||||
.Returns(ToAsyncEnumerableAsync([new ChatResponseUpdate(role: ChatRole.Assistant, content: "continued response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object, options: new()
|
||||
{
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
AIContextProviders = [mockContextProvider.Object]
|
||||
});
|
||||
|
||||
// Create a session
|
||||
ChatClientAgentSession? session = new();
|
||||
|
||||
AgentRunOptions runOptions = new()
|
||||
{
|
||||
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) { InputMessages = [new ChatMessage()] }
|
||||
};
|
||||
|
||||
// Act
|
||||
await agent.RunStreamingAsync(session, options: runOptions).ToListAsync();
|
||||
|
||||
// Assert
|
||||
// With continuation token, session message population should be skipped
|
||||
Assert.Empty(capturedMessages);
|
||||
|
||||
// Verify that chat history provider was never called due to continuation token
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", Times.Never(), ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>());
|
||||
|
||||
// Verify that AI context provider was never called due to continuation token
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask<AIContext>>("InvokingCoreAsync", Times.Never(), ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WhenNoSessionProvidedForBackgroundResponses_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
|
||||
AgentRunOptions runOptions = new() { AllowBackgroundResponses = true };
|
||||
|
||||
IEnumerable<ChatMessage> inputMessages = [new ChatMessage(ChatRole.User, "test message")];
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync(inputMessages, options: runOptions));
|
||||
|
||||
// Verify that the IChatClient was never called due to early validation
|
||||
mockChatClient.Verify(
|
||||
c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WhenNoSessionProvidedForBackgroundResponses_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
|
||||
AgentRunOptions runOptions = new() { AllowBackgroundResponses = true };
|
||||
|
||||
IEnumerable<ChatMessage> inputMessages = [new ChatMessage(ChatRole.User, "test message")];
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
{
|
||||
await foreach (var update in agent.RunStreamingAsync(inputMessages, options: runOptions))
|
||||
{
|
||||
// Should not reach here
|
||||
}
|
||||
});
|
||||
|
||||
// Verify that the IChatClient was never called due to early validation
|
||||
mockChatClient.Verify(
|
||||
c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WhenInputMessagesPresentInContinuationToken_ResumesStreamingAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatResponseUpdate[] returnUpdates =
|
||||
[
|
||||
new ChatResponseUpdate(role: ChatRole.Assistant, content: "continuation") { ConversationId = "conversation-id" },
|
||||
];
|
||||
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient
|
||||
.Setup(c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(ToAsyncEnumerableAsync(returnUpdates));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
|
||||
ChatClientAgentSession? session = new() { ConversationId = "conversation-id" };
|
||||
|
||||
AgentRunOptions runOptions = new()
|
||||
{
|
||||
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }))
|
||||
{
|
||||
InputMessages = [new ChatMessage(ChatRole.User, "previous message")]
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var updates = new List<AgentResponseUpdate>();
|
||||
await foreach (var update in agent.RunStreamingAsync(session, options: runOptions))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Single(updates);
|
||||
|
||||
// Verify that the IChatClient was called
|
||||
mockChatClient.Verify(
|
||||
c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WhenResponseUpdatesPresentInContinuationToken_ResumesStreamingAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatResponseUpdate[] returnUpdates =
|
||||
[
|
||||
new ChatResponseUpdate(role: ChatRole.Assistant, content: "continuation") { ConversationId = "conversation-id" },
|
||||
];
|
||||
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient
|
||||
.Setup(c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(ToAsyncEnumerableAsync(returnUpdates));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
|
||||
ChatClientAgentSession? session = new() { ConversationId = "conversation-id" };
|
||||
|
||||
AgentRunOptions runOptions = new()
|
||||
{
|
||||
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }))
|
||||
{
|
||||
ResponseUpdates = [new ChatResponseUpdate(ChatRole.Assistant, "previous update")]
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var updates = new List<AgentResponseUpdate>();
|
||||
await foreach (var update in agent.RunStreamingAsync(session, options: runOptions))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Single(updates);
|
||||
|
||||
// Verify that the IChatClient was called
|
||||
mockChatClient.Verify(
|
||||
c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WhenResumingStreaming_UsesUpdatesFromInitialRunForContextProviderAndChatHistoryProviderAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatResponseUpdate[] returnUpdates =
|
||||
[
|
||||
new ChatResponseUpdate(role: ChatRole.Assistant, content: "upon"),
|
||||
new ChatResponseUpdate(role: ChatRole.Assistant, content: " a"),
|
||||
new ChatResponseUpdate(role: ChatRole.Assistant, content: " time"),
|
||||
];
|
||||
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient
|
||||
.Setup(c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(ToAsyncEnumerableAsync(returnUpdates));
|
||||
|
||||
List<ChatMessage> capturedMessagesAddedToProvider = [];
|
||||
var mockChatHistoryProvider = new Mock<ChatHistoryProvider>(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["ChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<ChatHistoryProvider.InvokedContext, CancellationToken>((ctx, ct) => capturedMessagesAddedToProvider.AddRange(ctx.ResponseMessages ?? []))
|
||||
.Returns(new ValueTask());
|
||||
|
||||
AIContextProvider.InvokedContext? capturedInvokedContext = null;
|
||||
var mockContextProvider = new Mock<AIContextProvider>(null, null, null);
|
||||
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["Provider1"]);
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<AIContextProvider.InvokedContext, CancellationToken>((context, ct) => capturedInvokedContext = context)
|
||||
.Returns(new ValueTask());
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object, options: new()
|
||||
{
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
AIContextProviders = [mockContextProvider.Object]
|
||||
});
|
||||
|
||||
ChatClientAgentSession? session = new();
|
||||
|
||||
AgentRunOptions runOptions = new()
|
||||
{
|
||||
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }))
|
||||
{
|
||||
ResponseUpdates = [new ChatResponseUpdate(ChatRole.Assistant, "once ")]
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
await agent.RunStreamingAsync(session, options: runOptions).ToListAsync();
|
||||
|
||||
// Assert
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(), ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>());
|
||||
Assert.Single(capturedMessagesAddedToProvider);
|
||||
Assert.Contains("once upon a time", capturedMessagesAddedToProvider[0].Text);
|
||||
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(), ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>());
|
||||
Assert.NotNull(capturedInvokedContext?.ResponseMessages);
|
||||
Assert.Single(capturedInvokedContext.ResponseMessages);
|
||||
Assert.Contains("once upon a time", capturedInvokedContext.ResponseMessages.ElementAt(0).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WhenResumingStreaming_UsesInputMessagesFromInitialRunForContextProviderAndChatHistoryProviderAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient
|
||||
.Setup(c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(ToAsyncEnumerableAsync(Array.Empty<ChatResponseUpdate>()));
|
||||
|
||||
List<ChatMessage> capturedMessagesAddedToProvider = [];
|
||||
var mockChatHistoryProvider = new Mock<ChatHistoryProvider>(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["ChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<ChatHistoryProvider.InvokedContext, CancellationToken>((ctx, ct) => capturedMessagesAddedToProvider.AddRange(ctx.RequestMessages))
|
||||
.Returns(new ValueTask());
|
||||
|
||||
AIContextProvider.InvokedContext? capturedInvokedContext = null;
|
||||
var mockContextProvider = new Mock<AIContextProvider>(null, null, null);
|
||||
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["Provider1"]);
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<AIContextProvider.InvokedContext, CancellationToken>((context, ct) => capturedInvokedContext = context)
|
||||
.Returns(new ValueTask());
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object, options: new()
|
||||
{
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
AIContextProviders = [mockContextProvider.Object]
|
||||
});
|
||||
|
||||
ChatClientAgentSession? session = new();
|
||||
|
||||
AgentRunOptions runOptions = new()
|
||||
{
|
||||
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }))
|
||||
{
|
||||
InputMessages = [new ChatMessage(ChatRole.User, "Tell me a story")],
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
await agent.RunStreamingAsync(session, options: runOptions).ToListAsync();
|
||||
|
||||
// Assert
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(), ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>());
|
||||
Assert.Single(capturedMessagesAddedToProvider);
|
||||
Assert.Contains("Tell me a story", capturedMessagesAddedToProvider[0].Text);
|
||||
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(), ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>());
|
||||
Assert.NotNull(capturedInvokedContext?.RequestMessages);
|
||||
Assert.Single(capturedInvokedContext.RequestMessages);
|
||||
Assert.Contains("Tell me a story", capturedInvokedContext.RequestMessages.ElementAt(0).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WhenResumingStreaming_SavesInputMessagesAndUpdatesInContinuationTokenAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatResponseUpdate> returnUpdates =
|
||||
[
|
||||
new ChatResponseUpdate(role: ChatRole.Assistant, content: "Once") { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) },
|
||||
new ChatResponseUpdate(role: ChatRole.Assistant, content: " upon") { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) },
|
||||
new ChatResponseUpdate(role: ChatRole.Assistant, content: " a") { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) },
|
||||
new ChatResponseUpdate(role: ChatRole.Assistant, content: " time"){ ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) },
|
||||
];
|
||||
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient
|
||||
.Setup(c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(ToAsyncEnumerableAsync(returnUpdates));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
|
||||
ChatClientAgentSession? session = new() { };
|
||||
|
||||
List<ChatClientAgentContinuationToken> capturedContinuationTokens = [];
|
||||
|
||||
ChatMessage userMessage = new(ChatRole.User, "Tell me a story");
|
||||
|
||||
// Act
|
||||
|
||||
// Do the initial run
|
||||
await foreach (var update in agent.RunStreamingAsync(userMessage, session))
|
||||
{
|
||||
capturedContinuationTokens.Add(Assert.IsType<ChatClientAgentContinuationToken>(update.ContinuationToken));
|
||||
break;
|
||||
}
|
||||
|
||||
// Now resume the run using the captured continuation token
|
||||
returnUpdates.RemoveAt(0); // remove the first mock update as it was already processed
|
||||
var options = new AgentRunOptions { ContinuationToken = capturedContinuationTokens[0] };
|
||||
await foreach (var update in agent.RunStreamingAsync(session, options: options))
|
||||
{
|
||||
capturedContinuationTokens.Add(Assert.IsType<ChatClientAgentContinuationToken>(update.ContinuationToken));
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(4, capturedContinuationTokens.Count);
|
||||
|
||||
// Verify that the first continuation token has the initial input and first update
|
||||
Assert.NotNull(capturedContinuationTokens[0].InputMessages);
|
||||
Assert.Single(capturedContinuationTokens[0].InputMessages!);
|
||||
Assert.Equal("Tell me a story", capturedContinuationTokens[0].InputMessages!.Last().Text);
|
||||
Assert.NotNull(capturedContinuationTokens[0].ResponseUpdates);
|
||||
Assert.Single(capturedContinuationTokens[0].ResponseUpdates!);
|
||||
Assert.Equal("Once", capturedContinuationTokens[0].ResponseUpdates![0].Text);
|
||||
|
||||
// Verify the last continuation token has the input and all updates
|
||||
var lastToken = capturedContinuationTokens[^1];
|
||||
Assert.NotNull(lastToken.InputMessages);
|
||||
Assert.Single(lastToken.InputMessages!);
|
||||
Assert.Equal("Tell me a story", lastToken.InputMessages!.Last().Text);
|
||||
Assert.NotNull(lastToken.ResponseUpdates);
|
||||
Assert.Equal(4, lastToken.ResponseUpdates!.Count);
|
||||
Assert.Equal("Once", lastToken.ResponseUpdates!.ElementAt(0).Text);
|
||||
Assert.Equal(" upon", lastToken.ResponseUpdates!.ElementAt(1).Text);
|
||||
Assert.Equal(" a", lastToken.ResponseUpdates!.ElementAt(2).Text);
|
||||
Assert.Equal(" time", lastToken.ResponseUpdates!.ElementAt(3).Text);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<T> ToAsyncEnumerableAsync<T>(IEnumerable<T> values)
|
||||
{
|
||||
await Task.Yield();
|
||||
foreach (var update in values)
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
}
|
||||
+654
@@ -0,0 +1,654 @@
|
||||
// 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 Moq;
|
||||
using Moq.Protected;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains unit tests that verify the chat history management functionality of the <see cref="ChatClientAgent"/> class,
|
||||
/// e.g. that it correctly reads and updates chat history in any available <see cref="ChatHistoryProvider"/> or that
|
||||
/// it uses conversation id correctly for service managed chat history.
|
||||
/// </summary>
|
||||
public class ChatClientAgent_ChatHistoryManagementTests
|
||||
{
|
||||
#region ConversationId Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunAsync does not throw when providing a ConversationId via both AgentSession and
|
||||
/// via ChatOptions and the two are the same.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_DoesNotThrow_WhenSpecifyingTwoSameConversationIdsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var chatOptions = new ChatOptions { ConversationId = "ConvId" };
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.Is<ChatOptions>(opts => opts.ConversationId == "ConvId"),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" });
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } });
|
||||
|
||||
ChatClientAgentSession? session = new() { ConversationId = "ConvId" };
|
||||
|
||||
// Act & Assert
|
||||
var response = await agent.RunAsync([new(ChatRole.User, "test")], session, options: new ChatClientAgentRunOptions(chatOptions));
|
||||
Assert.NotNull(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunAsync throws when providing a ConversationId via both AgentSession and
|
||||
/// via ChatOptions and the two are different.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_Throws_WhenSpecifyingTwoDifferentConversationIdsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var chatOptions = new ChatOptions { ConversationId = "ConvId" };
|
||||
Mock<IChatClient> mockService = new();
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } });
|
||||
|
||||
ChatClientAgentSession? session = new() { ConversationId = "ThreadId" };
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], session, options: new ChatClientAgentRunOptions(chatOptions)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunAsync clones the ChatOptions when providing a session with a ConversationId and a ChatOptions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_ClonesChatOptions_ToAddConversationIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
var chatOptions = new ChatOptions { MaxOutputTokens = 100 };
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.Is<ChatOptions>(opts => opts.MaxOutputTokens == 100 && opts.ConversationId == "ConvId"),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" });
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } });
|
||||
|
||||
ChatClientAgentSession? session = new() { ConversationId = "ConvId" };
|
||||
|
||||
// Act
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session, options: new ChatClientAgentRunOptions(chatOptions));
|
||||
|
||||
// Assert
|
||||
Assert.Null(chatOptions.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunAsync throws if a session is provided that uses a conversation id already, but the service does not return one on invoke.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_Throws_ForMissingConversationIdWithConversationIdSessionAsync()
|
||||
{
|
||||
// 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() { ChatOptions = new() { Instructions = "test instructions" } });
|
||||
|
||||
ChatClientAgentSession? session = new() { ConversationId = "ConvId" };
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], session));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunAsync sets the ConversationId on the session when the service returns one.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_SetsConversationIdOnSession_WhenReturnedByChatClientAsync()
|
||||
{
|
||||
// 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")]) { ConversationId = "ConvId" });
|
||||
ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } });
|
||||
ChatClientAgentSession? session = new();
|
||||
|
||||
// Act
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("ConvId", session.ConversationId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ChatHistoryProvider Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunAsync uses the default InMemoryChatHistoryProvider when the chat client returns no conversation id.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_UsesDefaultInMemoryChatHistoryProvider_WhenNoConversationIdReturnedByChatClientAsync()
|
||||
{
|
||||
// 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()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "test instructions" },
|
||||
});
|
||||
|
||||
// Act
|
||||
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert
|
||||
var inMemoryProvider = agent.ChatHistoryProvider as InMemoryChatHistoryProvider;
|
||||
Assert.NotNull(inMemoryProvider);
|
||||
var messages = inMemoryProvider.GetMessages(session!);
|
||||
Assert.Equal(2, messages.Count);
|
||||
Assert.Equal("test", messages[0].Text);
|
||||
Assert.Equal("response", messages[1].Text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunAsync uses the ChatHistoryProvider when the chat client returns no conversation id.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_UsesChatHistoryProvider_WhenProvidedAndNoConversationIdReturnedByChatClientAsync()
|
||||
{
|
||||
// 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>>(new List<ChatMessage> { new(ChatRole.User, "Existing Chat History") }.Concat(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()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "test instructions" },
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object
|
||||
});
|
||||
|
||||
// Act
|
||||
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert
|
||||
Assert.Same(mockChatHistoryProvider.Object, agent.ChatHistoryProvider);
|
||||
mockService.Verify(
|
||||
x => x.GetResponseAsync(
|
||||
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Count() == 2 && msgs.Any(m => m.Text == "Existing Chat History") && msgs.Any(m => m.Text == "test")),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", Times.Once(),
|
||||
ItExpr.Is<ChatHistoryProvider.InvokingContext>(x => x.RequestMessages.Count() == 1),
|
||||
ItExpr.IsAny<CancellationToken>());
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
||||
ItExpr.Is<ChatHistoryProvider.InvokedContext>(x => x.RequestMessages.Count() == 2 && x.ResponseMessages!.Count() == 1),
|
||||
ItExpr.IsAny<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunAsync notifies the ChatHistoryProvider on failure.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_NotifiesChatHistoryProvider_OnFailureAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).Throws(new InvalidOperationException("Test Error"));
|
||||
|
||||
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()));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "test instructions" },
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object
|
||||
});
|
||||
|
||||
// Act
|
||||
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], session));
|
||||
|
||||
// Assert
|
||||
Assert.Same(mockChatHistoryProvider.Object, agent.ChatHistoryProvider);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
||||
ItExpr.Is<ChatHistoryProvider.InvokedContext>(x => x.RequestMessages.Count() == 1 && x.ResponseMessages == null && x.InvokeException!.Message == "Test Error"),
|
||||
ItExpr.IsAny<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunAsync throws when a ChatHistoryProvider is provided and the chat client returns a conversation id.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_Throws_WhenChatHistoryProviderProvidedAndConversationIdReturnedByChatClientAsync()
|
||||
{
|
||||
// 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")]) { ConversationId = "ConvId" });
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "test instructions" },
|
||||
ChatHistoryProvider = new InMemoryChatHistoryProvider()
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
InvalidOperationException exception = await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], session));
|
||||
Assert.Equal("Only ConversationId or ChatHistoryProvider may be used, but not both. The service returned a conversation id indicating server-side chat history management, but the agent has a ChatHistoryProvider configured.", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunAsync clears the ChatHistoryProvider when ThrowOnChatHistoryProviderConflict is false
|
||||
/// and ClearOnChatHistoryProviderConflict is true.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_ClearsChatHistoryProvider_WhenThrowDisabledAndClearEnabledAsync()
|
||||
{
|
||||
// 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")]) { ConversationId = "ConvId" });
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "test instructions" },
|
||||
ChatHistoryProvider = new InMemoryChatHistoryProvider(),
|
||||
ThrowOnChatHistoryProviderConflict = false,
|
||||
ClearOnChatHistoryProviderConflict = true,
|
||||
});
|
||||
|
||||
// Act
|
||||
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert
|
||||
Assert.Null(agent.ChatHistoryProvider);
|
||||
Assert.Equal("ConvId", session!.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunAsync does not throw and does not clear the ChatHistoryProvider when both
|
||||
/// ThrowOnChatHistoryProviderConflict and ClearOnChatHistoryProviderConflict are false.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_KeepsChatHistoryProvider_WhenThrowAndClearDisabledAsync()
|
||||
{
|
||||
// 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")]) { ConversationId = "ConvId" });
|
||||
var chatHistoryProvider = new InMemoryChatHistoryProvider();
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "test instructions" },
|
||||
ChatHistoryProvider = chatHistoryProvider,
|
||||
ThrowOnChatHistoryProviderConflict = false,
|
||||
ClearOnChatHistoryProviderConflict = false,
|
||||
WarnOnChatHistoryProviderConflict = false,
|
||||
});
|
||||
|
||||
// Act
|
||||
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert
|
||||
Assert.Same(chatHistoryProvider, agent.ChatHistoryProvider);
|
||||
Assert.Equal("ConvId", session!.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunAsync still throws when ThrowOnChatHistoryProviderConflict is true
|
||||
/// even if ClearOnChatHistoryProviderConflict is also true (throw takes precedence).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_Throws_WhenThrowEnabledRegardlessOfClearSettingAsync()
|
||||
{
|
||||
// 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")]) { ConversationId = "ConvId" });
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "test instructions" },
|
||||
ChatHistoryProvider = new InMemoryChatHistoryProvider(),
|
||||
ThrowOnChatHistoryProviderConflict = true,
|
||||
ClearOnChatHistoryProviderConflict = true,
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], session));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunAsync does not throw when no ChatHistoryProvider is configured on options,
|
||||
/// even if the service returns a conversation id (default InMemoryChatHistoryProvider is used but not from options).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_DoesNotThrow_WhenNoChatHistoryProviderInOptionsAndConversationIdReturnedAsync()
|
||||
{
|
||||
// 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")]) { ConversationId = "ConvId" });
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "test instructions" },
|
||||
});
|
||||
|
||||
// Act
|
||||
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert - no exception, session gets the conversation id
|
||||
Assert.Equal("ConvId", session!.ConversationId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ChatHistoryProvider Override Tests
|
||||
|
||||
/// <summary>
|
||||
/// Tests that RunAsync uses an override ChatHistoryProvider provided via AdditionalProperties instead of the provider from a factory
|
||||
/// if one is supplied.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_UsesOverrideChatHistoryProvider_WhenProvidedViaAdditionalPropertiesAsync()
|
||||
{
|
||||
// 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")]));
|
||||
|
||||
// Arrange a chat history provider to override the factory provided one.
|
||||
Mock<ChatHistoryProvider> mockOverrideChatHistoryProvider = new(null, null, null);
|
||||
mockOverrideChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
||||
mockOverrideChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<IEnumerable<ChatMessage>>(new List<ChatMessage> { new(ChatRole.User, "Existing Chat History") }.Concat(ctx.RequestMessages).ToList()));
|
||||
mockOverrideChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask());
|
||||
|
||||
// Arrange a chat history provider to provide to the agent at construction time.
|
||||
// This one shouldn't be used since it is being overridden.
|
||||
Mock<ChatHistoryProvider> mockAgentOptionsChatHistoryProvider = new(null, null, null);
|
||||
mockAgentOptionsChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
||||
mockAgentOptionsChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(FailException.ForFailure("Base ChatHistoryProvider shouldn't be used."));
|
||||
mockAgentOptionsChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Throws(FailException.ForFailure("Base ChatHistoryProvider shouldn't be used."));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "test instructions" },
|
||||
ChatHistoryProvider = mockAgentOptionsChatHistoryProvider.Object
|
||||
});
|
||||
|
||||
// Act
|
||||
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
AdditionalPropertiesDictionary additionalProperties = new();
|
||||
additionalProperties.Add(mockOverrideChatHistoryProvider.Object);
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session, options: new AgentRunOptions { AdditionalProperties = additionalProperties });
|
||||
|
||||
// Assert
|
||||
Assert.Same(mockAgentOptionsChatHistoryProvider.Object, agent.ChatHistoryProvider);
|
||||
mockService.Verify(
|
||||
x => x.GetResponseAsync(
|
||||
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Count() == 2 && msgs.Any(m => m.Text == "Existing Chat History") && msgs.Any(m => m.Text == "test")),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
mockOverrideChatHistoryProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", Times.Once(),
|
||||
ItExpr.Is<ChatHistoryProvider.InvokingContext>(x => x.RequestMessages.Count() == 1),
|
||||
ItExpr.IsAny<CancellationToken>());
|
||||
mockOverrideChatHistoryProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
||||
ItExpr.Is<ChatHistoryProvider.InvokedContext>(x => x.RequestMessages.Count() == 2 && x.ResponseMessages!.Count() == 1),
|
||||
ItExpr.IsAny<CancellationToken>());
|
||||
|
||||
mockAgentOptionsChatHistoryProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", Times.Never(),
|
||||
ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(),
|
||||
ItExpr.IsAny<CancellationToken>());
|
||||
mockAgentOptionsChatHistoryProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Never(),
|
||||
ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(),
|
||||
ItExpr.IsAny<CancellationToken>());
|
||||
}
|
||||
|
||||
#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" },
|
||||
RequirePerServiceCallChatHistoryPersistence = true,
|
||||
},
|
||||
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] },
|
||||
RequirePerServiceCallChatHistoryPersistence = 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 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" },
|
||||
},
|
||||
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] },
|
||||
},
|
||||
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" },
|
||||
},
|
||||
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
|
||||
}
|
||||
+552
@@ -0,0 +1,552 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for <see cref="ChatOptions"/> merging in <see cref="ChatClientAgent"/>.
|
||||
/// </summary>
|
||||
public class ChatClientAgent_ChatOptionsMergingTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify that ChatOptions merging works when agent has ChatOptions but request doesn't.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatOptionsMergingUsesAgentOptionsWhenRequestHasNoneAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agentChatOptions = new ChatOptions { MaxOutputTokens = 100, Temperature = 0.7f, Instructions = "test instructions" };
|
||||
Mock<IChatClient> mockService = new();
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
|
||||
capturedChatOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = agentChatOptions
|
||||
});
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.Equal(100, capturedChatOptions.MaxOutputTokens);
|
||||
Assert.Equal(0.7f, capturedChatOptions.Temperature);
|
||||
Assert.Equal("test instructions", capturedChatOptions.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChatOptionsMergingUsesAgentOptionsConstructorWhenRequestHasNoneAsync()
|
||||
{
|
||||
Mock<IChatClient> mockService = new();
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
|
||||
capturedChatOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } });
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.Equal("test instructions", capturedChatOptions.Instructions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that ChatOptions merging works when request has ChatOptions but agent doesn't.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatOptionsMergingUsesRequestOptionsWhenAgentHasNoneAsync()
|
||||
{
|
||||
// Arrange
|
||||
var requestChatOptions = new ChatOptions { MaxOutputTokens = 200, Temperature = 0.3f, Instructions = "test instructions" };
|
||||
Mock<IChatClient> mockService = new();
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
|
||||
capturedChatOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object);
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(requestChatOptions));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.Equivalent(requestChatOptions, capturedChatOptions); // Should be the same instance since no merging needed
|
||||
Assert.Equal(200, capturedChatOptions.MaxOutputTokens);
|
||||
Assert.Equal(0.3f, capturedChatOptions.Temperature);
|
||||
Assert.Equal("test instructions", capturedChatOptions.Instructions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that <see cref="ChatOptions"/> merging prioritizes <see cref="AgentRunOptions"/> over request <see cref="ChatOptions"/> and that in turn over agent level <see cref="ChatOptions"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatOptionsMergingPrioritizesRequestOptionsOverAgentOptionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agentChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = "test instructions",
|
||||
MaxOutputTokens = 100,
|
||||
Temperature = 0.7f,
|
||||
TopP = 0.9f,
|
||||
ModelId = "agent-model",
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary { ["key1"] = "agent-value", ["key2"] = "agent-value", ["key3"] = "agent-value" }
|
||||
};
|
||||
var requestChatOptions = new ChatOptions
|
||||
{
|
||||
// TopP and ModelId not set, should use agent values
|
||||
MaxOutputTokens = 200,
|
||||
Temperature = 0.3f,
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary { ["key2"] = "request-value", ["key3"] = "request-value" },
|
||||
Instructions = "request instructions"
|
||||
};
|
||||
var agentRunOptionsAdditionalProperties = new AdditionalPropertiesDictionary { ["key3"] = "runoptions-value" };
|
||||
var expectedChatOptionsMerge = new ChatOptions
|
||||
{
|
||||
MaxOutputTokens = 200, // Request value takes priority
|
||||
Temperature = 0.3f, // Request value takes priority
|
||||
// Check that each level of precedence is respected in AdditionalProperties
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary { ["key1"] = "agent-value", ["key2"] = "request-value", ["key3"] = "runoptions-value" },
|
||||
TopP = 0.9f, // Agent value used when request doesn't specify
|
||||
ModelId = "agent-model", // Agent value used when request doesn't specify
|
||||
Instructions = "test instructions\nrequest instructions" // Request is in addition to agent instructions
|
||||
};
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
|
||||
capturedChatOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = agentChatOptions
|
||||
});
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(requestChatOptions) { AdditionalProperties = agentRunOptionsAdditionalProperties });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.Equivalent(expectedChatOptionsMerge, capturedChatOptions); // Should be the same instance (modified in place)
|
||||
Assert.Equal(200, capturedChatOptions.MaxOutputTokens); // Request value takes priority
|
||||
Assert.Equal(0.3f, capturedChatOptions.Temperature); // Request value takes priority
|
||||
Assert.NotNull(capturedChatOptions.AdditionalProperties);
|
||||
Assert.Equal("agent-value", capturedChatOptions.AdditionalProperties["key1"]); // Agent value used when request doesn't specify
|
||||
Assert.Equal("request-value", capturedChatOptions.AdditionalProperties["key2"]); // Request ChatOptions value takes priority over agent ChatOptions value
|
||||
Assert.Equal("runoptions-value", capturedChatOptions.AdditionalProperties["key3"]); // Run options value takes priority over request and agent ChatOptions values
|
||||
Assert.Equal(0.9f, capturedChatOptions.TopP); // Agent value used when request doesn't specify
|
||||
Assert.Equal("agent-model", capturedChatOptions.ModelId); // Agent value used when request doesn't specify
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that when both agent and request have no ChatOptions, the inner client
|
||||
/// receives null options.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatOptionsMergingReturnsNullChatOptionsWhenBothAgentAndRequestHaveNoneAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((msgs, opts, ct) =>
|
||||
capturedChatOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object);
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Null(capturedChatOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that ChatOptions merging concatenates Tools from agent and request.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatOptionsMergingConcatenatesToolsFromAgentAndRequestAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agentTool = AIFunctionFactory.Create(() => "agent tool");
|
||||
var requestTool = AIFunctionFactory.Create(() => "request tool");
|
||||
|
||||
var agentChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = "test instructions",
|
||||
Tools = [agentTool]
|
||||
};
|
||||
var requestChatOptions = new ChatOptions
|
||||
{
|
||||
Tools = [requestTool]
|
||||
};
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
|
||||
capturedChatOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = agentChatOptions
|
||||
});
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(requestChatOptions));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.NotNull(capturedChatOptions.Tools);
|
||||
Assert.Equal(2, capturedChatOptions.Tools.Count);
|
||||
|
||||
// Request tools should come first, then agent tools
|
||||
Assert.Contains(requestTool, capturedChatOptions.Tools);
|
||||
Assert.Contains(agentTool, capturedChatOptions.Tools);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that ChatOptions merging uses agent Tools when request has no Tools.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatOptionsMergingUsesAgentToolsWhenRequestHasNoToolsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agentTool = AIFunctionFactory.Create(() => "agent tool");
|
||||
|
||||
var agentChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = "test instructions",
|
||||
Tools = [agentTool]
|
||||
};
|
||||
var requestChatOptions = new ChatOptions
|
||||
{
|
||||
// No Tools specified
|
||||
MaxOutputTokens = 100
|
||||
};
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
|
||||
capturedChatOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = agentChatOptions
|
||||
});
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(requestChatOptions));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.NotNull(capturedChatOptions.Tools);
|
||||
Assert.Single(capturedChatOptions.Tools);
|
||||
Assert.Contains(agentTool, capturedChatOptions.Tools); // Should contain the agent's tool
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that ChatOptions merging uses RawRepresentationFactory from request first, with fallback to agent.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("MockAgentSetting", "MockRequestSetting", "MockRequestSetting")]
|
||||
[InlineData("MockAgentSetting", null, "MockAgentSetting")]
|
||||
[InlineData(null, "MockRequestSetting", "MockRequestSetting")]
|
||||
public async Task ChatOptionsMergingUsesRawRepresentationFactoryWithFallbackAsync(string? agentSetting, string? requestSetting, string expectedSetting)
|
||||
{
|
||||
// Arrange
|
||||
var agentChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = "test instructions",
|
||||
RawRepresentationFactory = _ => agentSetting
|
||||
};
|
||||
var requestChatOptions = new ChatOptions
|
||||
{
|
||||
RawRepresentationFactory = _ => requestSetting
|
||||
};
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
|
||||
capturedChatOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = agentChatOptions
|
||||
});
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(requestChatOptions));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.NotNull(capturedChatOptions.RawRepresentationFactory);
|
||||
Assert.Equal(expectedSetting, capturedChatOptions.RawRepresentationFactory(null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that <see cref="ChatOptions.Reasoning"/> from the request takes priority over the agent's.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatOptionsMergingUsesRequestReasoningOverAgentReasoningAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agentReasoning = new ReasoningOptions { Effort = ReasoningEffort.Low, Output = ReasoningOutput.Full };
|
||||
var requestReasoning = new ReasoningOptions { Effort = ReasoningEffort.High, Output = ReasoningOutput.Full };
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
|
||||
capturedChatOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new ChatOptions { Reasoning = agentReasoning }
|
||||
});
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(new ChatOptions { Reasoning = requestReasoning }));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.NotNull(capturedChatOptions.Reasoning);
|
||||
Assert.Equal(requestReasoning.Effort, capturedChatOptions.Reasoning.Effort);
|
||||
Assert.Equal(requestReasoning.Output, capturedChatOptions.Reasoning.Output);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that <see cref="ChatOptions.Reasoning"/> falls back to the agent's when the request has none.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatOptionsMergingFallsBackToAgentReasoningWhenRequestHasNoneAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agentReasoning = new ReasoningOptions { Effort = ReasoningEffort.Low, Output = ReasoningOutput.Full };
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
|
||||
capturedChatOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new ChatOptions { Reasoning = agentReasoning }
|
||||
});
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(new ChatOptions()));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.NotNull(capturedChatOptions.Reasoning);
|
||||
Assert.Equal(agentReasoning.Effort, capturedChatOptions.Reasoning.Effort);
|
||||
Assert.Equal(agentReasoning.Output, capturedChatOptions.Reasoning.Output);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that <see cref="ChatOptions.Reasoning"/> from the request is used when the agent has none.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatOptionsMergingUsesRequestReasoningWhenAgentHasNoneAsync()
|
||||
{
|
||||
// Arrange
|
||||
var requestReasoning = new ReasoningOptions { Effort = ReasoningEffort.High, Output = ReasoningOutput.Full };
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
|
||||
capturedChatOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new ChatOptions()
|
||||
});
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(new ChatOptions { Reasoning = requestReasoning }));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.NotNull(capturedChatOptions.Reasoning);
|
||||
Assert.Equal(requestReasoning.Effort, capturedChatOptions.Reasoning.Effort);
|
||||
Assert.Equal(requestReasoning.Output, capturedChatOptions.Reasoning.Output);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that ChatOptions merging handles all scalar properties correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatOptionsMergingHandlesAllScalarPropertiesCorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agentChatOptions = new ChatOptions
|
||||
{
|
||||
MaxOutputTokens = 100,
|
||||
Temperature = 0.7f,
|
||||
TopP = 0.9f,
|
||||
TopK = 50,
|
||||
PresencePenalty = 0.1f,
|
||||
FrequencyPenalty = 0.2f,
|
||||
Instructions = "agent instructions",
|
||||
ModelId = "agent-model",
|
||||
Seed = 12345,
|
||||
ConversationId = "agent-conversation",
|
||||
AllowMultipleToolCalls = true,
|
||||
StopSequences = ["agent-stop"]
|
||||
};
|
||||
var requestChatOptions = new ChatOptions
|
||||
{
|
||||
MaxOutputTokens = 200,
|
||||
Temperature = 0.3f,
|
||||
Instructions = "request instructions",
|
||||
|
||||
// Other properties not set, should use agent values
|
||||
StopSequences = ["request-stop"]
|
||||
};
|
||||
|
||||
var expectedChatOptionsMerge = new ChatOptions
|
||||
{
|
||||
MaxOutputTokens = 200,
|
||||
Temperature = 0.3f,
|
||||
|
||||
// Agent value used when request doesn't specify
|
||||
TopP = 0.9f,
|
||||
TopK = 50,
|
||||
PresencePenalty = 0.1f,
|
||||
FrequencyPenalty = 0.2f,
|
||||
Instructions = "agent instructions\nrequest instructions",
|
||||
ModelId = "agent-model",
|
||||
Seed = 12345,
|
||||
ConversationId = "agent-conversation",
|
||||
AllowMultipleToolCalls = true,
|
||||
|
||||
// Merged StopSequences
|
||||
StopSequences = ["request-stop", "agent-stop"]
|
||||
};
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
|
||||
capturedChatOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = agentChatOptions
|
||||
});
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(requestChatOptions));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.Equivalent(expectedChatOptionsMerge, capturedChatOptions); // Should be the equivalent instance (modified in place)
|
||||
|
||||
// Request values should take priority
|
||||
Assert.Equal(200, capturedChatOptions.MaxOutputTokens);
|
||||
Assert.Equal(0.3f, capturedChatOptions.Temperature);
|
||||
|
||||
// Merge StopSequences
|
||||
Assert.Equal(["request-stop", "agent-stop"], capturedChatOptions.StopSequences);
|
||||
|
||||
// Agent values should be used when request doesn't specify
|
||||
Assert.Equal(0.9f, capturedChatOptions.TopP);
|
||||
Assert.Equal(50, capturedChatOptions.TopK);
|
||||
Assert.Equal(0.1f, capturedChatOptions.PresencePenalty);
|
||||
Assert.Equal(0.2f, capturedChatOptions.FrequencyPenalty);
|
||||
Assert.Equal("agent-model", capturedChatOptions.ModelId);
|
||||
Assert.Equal(12345, capturedChatOptions.Seed);
|
||||
Assert.Equal("agent-conversation", capturedChatOptions.ConversationId);
|
||||
Assert.Equal(true, capturedChatOptions.AllowMultipleToolCalls);
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains unit tests for the ChatClientAgent.CreateSessionAsync methods.
|
||||
/// </summary>
|
||||
public class ChatClientAgent_CreateSessionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateSession_UsesConversationId_FromTypedOverloadAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockChatClient = new Mock<IChatClient>();
|
||||
const string TestConversationId = "test_conversation_id";
|
||||
var agent = new ChatClientAgent(mockChatClient.Object);
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync(TestConversationId);
|
||||
|
||||
// Assert
|
||||
Assert.IsType<ChatClientAgentSession>(session);
|
||||
var typedSession = (ChatClientAgentSession)session;
|
||||
Assert.Equal(TestConversationId, typedSession.ConversationId);
|
||||
}
|
||||
}
|
||||
+456
@@ -0,0 +1,456 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ChatClientAgent"/> run methods with <see cref="ChatClientAgentRunOptions"/>.
|
||||
/// </summary>
|
||||
public sealed partial class ChatClientAgent_RunWithCustomOptionsTests
|
||||
{
|
||||
#region RunAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithSessionAndOptions_CallsBaseMethodAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "Response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
ChatClientAgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
AgentResponse result = await agent.RunAsync(session, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Messages);
|
||||
mockChatClient.Verify(
|
||||
x => x.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithStringMessageAndOptions_CallsBaseMethodAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "Response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
ChatClientAgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
AgentResponse result = await agent.RunAsync("Test message", session, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Messages);
|
||||
mockChatClient.Verify(
|
||||
x => x.GetResponseAsync(
|
||||
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Any(m => m.Text == "Test message")),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithChatMessageAndOptions_CallsBaseMethodAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "Response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
ChatMessage message = new(ChatRole.User, "Test message");
|
||||
ChatClientAgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
AgentResponse result = await agent.RunAsync(message, session, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Messages);
|
||||
mockChatClient.Verify(
|
||||
x => x.GetResponseAsync(
|
||||
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Contains(message)),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithMessagesCollectionAndOptions_CallsBaseMethodAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "Response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
IEnumerable<ChatMessage> messages = [new(ChatRole.User, "Message 1"), new(ChatRole.User, "Message 2")];
|
||||
ChatClientAgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
AgentResponse result = await agent.RunAsync(messages, session, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Messages);
|
||||
mockChatClient.Verify(
|
||||
x => x.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithChatOptionsInRunOptions_UsesChatOptionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "Response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
ChatClientAgentRunOptions options = new(new ChatOptions { Temperature = 0.5f });
|
||||
|
||||
// Act
|
||||
AgentResponse result = await agent.RunAsync("Test", null, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
mockChatClient.Verify(
|
||||
x => x.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.Is<ChatOptions>(opts => opts.Temperature == 0.5f),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RunStreamingAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WithSessionAndOptions_CallsBaseMethodAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient.Setup(
|
||||
s => s.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).Returns(GetAsyncUpdatesAsync());
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
ChatClientAgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
var updates = new List<AgentResponseUpdate>();
|
||||
await foreach (var update in agent.RunStreamingAsync(session, options))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(updates);
|
||||
mockChatClient.Verify(
|
||||
x => x.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WithStringMessageAndOptions_CallsBaseMethodAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient.Setup(
|
||||
s => s.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).Returns(GetAsyncUpdatesAsync());
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
ChatClientAgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
var updates = new List<AgentResponseUpdate>();
|
||||
await foreach (var update in agent.RunStreamingAsync("Test message", session, options))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(updates);
|
||||
mockChatClient.Verify(
|
||||
x => x.GetStreamingResponseAsync(
|
||||
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Any(m => m.Text == "Test message")),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WithChatMessageAndOptions_CallsBaseMethodAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient.Setup(
|
||||
s => s.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).Returns(GetAsyncUpdatesAsync());
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
ChatMessage message = new(ChatRole.User, "Test message");
|
||||
ChatClientAgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
var updates = new List<AgentResponseUpdate>();
|
||||
await foreach (var update in agent.RunStreamingAsync(message, session, options))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(updates);
|
||||
mockChatClient.Verify(
|
||||
x => x.GetStreamingResponseAsync(
|
||||
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Contains(message)),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WithMessagesCollectionAndOptions_CallsBaseMethodAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient.Setup(
|
||||
s => s.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).Returns(GetAsyncUpdatesAsync());
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
IEnumerable<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Message 1"), new ChatMessage(ChatRole.User, "Message 2")];
|
||||
ChatClientAgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
var updates = new List<AgentResponseUpdate>();
|
||||
await foreach (var update in agent.RunStreamingAsync(messages, session, options))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(updates);
|
||||
mockChatClient.Verify(
|
||||
x => x.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static async IAsyncEnumerable<ChatResponseUpdate> GetAsyncUpdatesAsync()
|
||||
{
|
||||
yield return new ChatResponseUpdate { Contents = new[] { new TextContent("Hello") } };
|
||||
yield return new ChatResponseUpdate { Contents = new[] { new TextContent(" World") } };
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RunAsync{T} Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsyncOfT_WithSessionAndOptions_CallsBaseMethodAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, """{"id":2, "fullName":"Tigger", "species":"Tiger"}""")]));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
ChatClientAgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
AgentResponse<Animal> agentResponse = await agent.RunAsync<Animal>(session, JsonContext_WithCustomRunOptions.Default.Options, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agentResponse);
|
||||
Assert.Single(agentResponse.Messages);
|
||||
Assert.Equal("Tigger", agentResponse.Result.FullName);
|
||||
mockChatClient.Verify(
|
||||
x => x.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsyncOfT_WithStringMessageAndOptions_CallsBaseMethodAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, """{"id":2, "fullName":"Tigger", "species":"Tiger"}""")]));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
ChatClientAgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
AgentResponse<Animal> agentResponse = await agent.RunAsync<Animal>("Test message", session, JsonContext_WithCustomRunOptions.Default.Options, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agentResponse);
|
||||
Assert.Single(agentResponse.Messages);
|
||||
Assert.Equal("Tigger", agentResponse.Result.FullName);
|
||||
mockChatClient.Verify(
|
||||
x => x.GetResponseAsync(
|
||||
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Any(m => m.Text == "Test message")),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsyncOfT_WithChatMessageAndOptions_CallsBaseMethodAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, """{"id":2, "fullName":"Tigger", "species":"Tiger"}""")]));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
ChatMessage message = new(ChatRole.User, "Test message");
|
||||
ChatClientAgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
AgentResponse<Animal> agentResponse = await agent.RunAsync<Animal>(message, session, JsonContext_WithCustomRunOptions.Default.Options, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agentResponse);
|
||||
Assert.Single(agentResponse.Messages);
|
||||
Assert.Equal("Tigger", agentResponse.Result.FullName);
|
||||
mockChatClient.Verify(
|
||||
x => x.GetResponseAsync(
|
||||
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Contains(message)),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsyncOfT_WithMessagesCollectionAndOptions_CallsBaseMethodAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, """{"id":2, "fullName":"Tigger", "species":"Tiger"}""")]));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
IEnumerable<ChatMessage> messages = [new(ChatRole.User, "Message 1"), new(ChatRole.User, "Message 2")];
|
||||
ChatClientAgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
AgentResponse<Animal> agentResponse = await agent.RunAsync<Animal>(messages, session, JsonContext_WithCustomRunOptions.Default.Options, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agentResponse);
|
||||
Assert.Single(agentResponse.Messages);
|
||||
Assert.Equal("Tigger", agentResponse.Result.FullName);
|
||||
mockChatClient.Verify(
|
||||
x => x.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private sealed class Animal
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? FullName { get; set; }
|
||||
public Species Species { get; set; }
|
||||
}
|
||||
|
||||
private enum Species
|
||||
{
|
||||
Bear,
|
||||
Tiger,
|
||||
Walrus,
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(UseStringEnumConverter = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||
[JsonSerializable(typeof(Animal))]
|
||||
private sealed partial class JsonContext_WithCustomRunOptions : JsonSerializerContext;
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
public partial class ChatClientAgent_StructuredOutput_WithFormatResponseTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RunAsync_ResponseFormatProvidedAtAgentInitialization_IsPropagatedToChatClientAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatResponseFormat? capturedResponseFormat = 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, opts, ct) => capturedResponseFormat = opts?.ResponseFormat)
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test"))
|
||||
{
|
||||
ResponseId = "test",
|
||||
});
|
||||
|
||||
ChatResponseFormatJson responseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions()
|
||||
{
|
||||
ResponseFormat = responseFormat
|
||||
}
|
||||
});
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages: [new(ChatRole.User, "Hello")]);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedResponseFormat);
|
||||
Assert.Same(responseFormat, capturedResponseFormat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ResponseFormatProvidedAtAgentInvocation_IsPropagatedToChatClientAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatResponseFormat? capturedResponseFormat = 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, opts, ct) => capturedResponseFormat = opts?.ResponseFormat)
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test"))
|
||||
{
|
||||
ResponseId = "test",
|
||||
});
|
||||
|
||||
ChatResponseFormatJson responseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object);
|
||||
|
||||
ChatClientAgentRunOptions runOptions = new()
|
||||
{
|
||||
ResponseFormat = responseFormat
|
||||
};
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages: [new(ChatRole.User, "Hello")], options: runOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedResponseFormat);
|
||||
Assert.Same(responseFormat, capturedResponseFormat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ResponseFormatProvidedAtAgentInvocation_OverridesOneProvidedAtAgentInitializationAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatResponseFormat? capturedResponseFormat = 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, opts, ct) => capturedResponseFormat = opts?.ResponseFormat)
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test"))
|
||||
{
|
||||
ResponseId = "test",
|
||||
});
|
||||
|
||||
ChatResponseFormatJson initializationResponseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
|
||||
ChatResponseFormatJson invocationResponseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions()
|
||||
{
|
||||
ResponseFormat = initializationResponseFormat
|
||||
},
|
||||
});
|
||||
|
||||
ChatClientAgentRunOptions runOptions = new()
|
||||
{
|
||||
ResponseFormat = invocationResponseFormat
|
||||
};
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages: [new(ChatRole.User, "Hello")], options: runOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedResponseFormat);
|
||||
Assert.Same(invocationResponseFormat, capturedResponseFormat);
|
||||
Assert.NotSame(initializationResponseFormat, capturedResponseFormat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ResponseFormatProvidedAtAgentRunOptions_OverridesOneProvidedViaChatOptionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatResponseFormat? capturedResponseFormat = 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, opts, ct) => capturedResponseFormat = opts?.ResponseFormat)
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test"))
|
||||
{
|
||||
ResponseId = "test",
|
||||
});
|
||||
|
||||
ChatResponseFormatJson chatOptionsResponseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
|
||||
ChatResponseFormatJson runOptionsResponseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object);
|
||||
|
||||
ChatClientAgentRunOptions runOptions = new()
|
||||
{
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ResponseFormat = chatOptionsResponseFormat
|
||||
},
|
||||
ResponseFormat = runOptionsResponseFormat
|
||||
};
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages: [new(ChatRole.User, "Hello")], options: runOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedResponseFormat);
|
||||
Assert.Same(runOptionsResponseFormat, capturedResponseFormat);
|
||||
Assert.NotSame(chatOptionsResponseFormat, capturedResponseFormat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_StructuredOutputResponse_IsAvailableAsTextOnAgentResponseAsync()
|
||||
{
|
||||
// Arrange
|
||||
Animal expectedAnimal = new() { FullName = "Wally the Walrus", Id = 1, Species = Species.Walrus };
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(s => s
|
||||
.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedAnimal, JsonContext4.Default.Animal)))
|
||||
{
|
||||
ResponseId = "test",
|
||||
});
|
||||
|
||||
ChatResponseFormatJson responseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions()
|
||||
{
|
||||
ResponseFormat = responseFormat
|
||||
},
|
||||
});
|
||||
|
||||
// Act
|
||||
AgentResponse agentResponse = await agent.RunAsync(messages: [new(ChatRole.User, "Hello")]);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agentResponse?.Text);
|
||||
|
||||
Animal? deserialised = JsonSerializer.Deserialize(agentResponse.Text, JsonContext4.Default.Animal);
|
||||
Assert.NotNull(deserialised);
|
||||
Assert.Equal(expectedAnimal.Id, deserialised.Id);
|
||||
Assert.Equal(expectedAnimal.FullName, deserialised.FullName);
|
||||
Assert.Equal(expectedAnimal.Species, deserialised.Species);
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(Animal))]
|
||||
private sealed partial class JsonContext4 : JsonSerializerContext;
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
public partial class ChatClientAgent_StructuredOutput_WithRunAsyncTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RunAsync_WithGenericType_SetsJsonSchemaResponseFormatAndDeserializesResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatResponseFormat? capturedResponseFormat = null;
|
||||
ChatResponseFormatJson expectedResponseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext3.Default.Options);
|
||||
Animal expectedSO = new() { Id = 1, FullName = "Tigger", Species = Species.Tiger };
|
||||
|
||||
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, opts, ct) => capturedResponseFormat = opts?.ResponseFormat)
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedSO, JsonContext3.Default.Animal)))
|
||||
{
|
||||
ResponseId = "test",
|
||||
});
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object);
|
||||
|
||||
// Act
|
||||
AgentResponse<Animal> agentResponse = await agent.RunAsync<Animal>(
|
||||
messages: [new(ChatRole.User, "Hello")],
|
||||
serializerOptions: JsonContext3.Default.Options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedResponseFormat);
|
||||
Assert.Equal(expectedResponseFormat.Schema?.GetRawText(), ((ChatResponseFormatJson)capturedResponseFormat).Schema?.GetRawText());
|
||||
|
||||
Animal animal = agentResponse.Result;
|
||||
Assert.NotNull(animal);
|
||||
Assert.Equal(expectedSO.Id, animal.Id);
|
||||
Assert.Equal(expectedSO.FullName, animal.FullName);
|
||||
Assert.Equal(expectedSO.Species, animal.Species);
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(UseStringEnumConverter = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||
[JsonSerializable(typeof(Animal))]
|
||||
private sealed partial class JsonContext3 : JsonSerializerContext;
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains unit tests for the <see cref="ChatClientBuilderExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class ChatClientBuilderExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildAIAgent_WithBasicParameters_CreatesAgent()
|
||||
{
|
||||
// Arrange
|
||||
var innerChatClientMock = new Mock<IChatClient>();
|
||||
var builder = new ChatClientBuilder(innerChatClientMock.Object);
|
||||
|
||||
// Act
|
||||
var agent = builder.BuildAIAgent(
|
||||
instructions: "Test instructions",
|
||||
name: "TestAgent",
|
||||
description: "Test description"
|
||||
);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("TestAgent", agent.Name);
|
||||
Assert.Equal("Test description", agent.Description);
|
||||
Assert.Equal("Test instructions", agent.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAIAgent_WithTools_SetsToolsInOptions()
|
||||
{
|
||||
// Arrange
|
||||
var innerChatClientMock = new Mock<IChatClient>();
|
||||
var builder = new ChatClientBuilder(innerChatClientMock.Object);
|
||||
var tools = new List<AITool> { new Mock<AITool>().Object };
|
||||
|
||||
// Act
|
||||
var agent = builder.BuildAIAgent(tools: tools);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.NotNull(agent.ChatOptions);
|
||||
Assert.Equal(tools, agent.ChatOptions.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAIAgent_WithAllParameters_CreatesAgentCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var innerChatClientMock = new Mock<IChatClient>();
|
||||
var builder = new ChatClientBuilder(innerChatClientMock.Object);
|
||||
var tools = new List<AITool> { new Mock<AITool>().Object };
|
||||
var loggerFactoryMock = new Mock<ILoggerFactory>();
|
||||
var serviceProviderMock = new Mock<IServiceProvider>();
|
||||
|
||||
// Act
|
||||
var agent = builder.BuildAIAgent(
|
||||
instructions: "Complex instructions",
|
||||
name: "ComplexAgent",
|
||||
description: "Complex description",
|
||||
tools: tools,
|
||||
loggerFactory: loggerFactoryMock.Object,
|
||||
services: serviceProviderMock.Object
|
||||
);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("ComplexAgent", agent.Name);
|
||||
Assert.Equal("Complex description", agent.Description);
|
||||
Assert.Equal("Complex instructions", agent.Instructions);
|
||||
Assert.NotNull(agent.ChatOptions);
|
||||
Assert.Equal(tools, agent.ChatOptions.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAIAgent_WithOptions_CreatesAgentWithOptions()
|
||||
{
|
||||
// Arrange
|
||||
var innerChatClientMock = new Mock<IChatClient>();
|
||||
var builder = new ChatClientBuilder(innerChatClientMock.Object);
|
||||
var options = new ChatClientAgentOptions
|
||||
{
|
||||
Name = "AgentWithOptions",
|
||||
Description = "Desc",
|
||||
ChatOptions = new() { Instructions = "Instr" },
|
||||
UseProvidedChatClientAsIs = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = builder.BuildAIAgent(options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("AgentWithOptions", agent.Name);
|
||||
Assert.Equal("Desc", agent.Description);
|
||||
Assert.Equal("Instr", agent.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAIAgent_WithOptionsAndServices_CreatesAgentCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var innerChatClientMock = new Mock<IChatClient>();
|
||||
var builder = new ChatClientBuilder(innerChatClientMock.Object);
|
||||
var loggerFactoryMock = new Mock<ILoggerFactory>();
|
||||
var serviceProviderMock = new Mock<IServiceProvider>();
|
||||
var options = new ChatClientAgentOptions
|
||||
{
|
||||
Name = "ServiceAgent",
|
||||
ChatOptions = new() { Instructions = "Service instructions" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = builder.BuildAIAgent(
|
||||
options: options,
|
||||
loggerFactory: loggerFactoryMock.Object,
|
||||
services: serviceProviderMock.Object
|
||||
);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("ServiceAgent", agent.Name);
|
||||
Assert.Equal("Service instructions", agent.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAIAgent_WithNullBuilder_Throws()
|
||||
{
|
||||
// Arrange
|
||||
ChatClientBuilder builder = null!;
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => builder.BuildAIAgent(instructions: "instructions"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAIAgent_WithNullBuilderAndOptions_Throws()
|
||||
{
|
||||
// Arrange
|
||||
ChatClientBuilder builder = null!;
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => builder.BuildAIAgent(options: new() { ChatOptions = new() { Instructions = "instructions" } }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAIAgent_WithMiddleware_BuildsCorrectPipeline()
|
||||
{
|
||||
// Arrange
|
||||
var innerChatClientMock = new Mock<IChatClient>();
|
||||
var middlewareChatClientMock = new Mock<IChatClient>();
|
||||
var builder = new ChatClientBuilder(innerChatClientMock.Object);
|
||||
|
||||
// Add middleware that returns our mock
|
||||
builder.Use((client, services) => middlewareChatClientMock.Object);
|
||||
|
||||
// Act
|
||||
var agent = builder.BuildAIAgent(
|
||||
new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new() { Instructions = "Middleware test" },
|
||||
UseProvidedChatClientAsIs = true
|
||||
}
|
||||
);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("Middleware test", agent.Instructions);
|
||||
// When UseProvidedChatClientAsIs is true, the agent should use the middleware chat client directly
|
||||
Assert.Same(middlewareChatClientMock.Object, agent.ChatClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAIAgent_WithNullOptions_CreatesAgentWithDefaults()
|
||||
{
|
||||
// Arrange
|
||||
var innerChatClientMock = new Mock<IChatClient>();
|
||||
var builder = new ChatClientBuilder(innerChatClientMock.Object);
|
||||
|
||||
// Act
|
||||
var agent = builder.BuildAIAgent(options: null);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Null(agent.Name);
|
||||
Assert.Null(agent.Description);
|
||||
Assert.Null(agent.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAIAgent_WithEmptyParameters_CreatesMinimalAgent()
|
||||
{
|
||||
// Arrange
|
||||
var innerChatClientMock = new Mock<IChatClient>();
|
||||
var builder = new ChatClientBuilder(innerChatClientMock.Object);
|
||||
|
||||
// Act
|
||||
var agent = builder.BuildAIAgent();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Null(agent.Name);
|
||||
Assert.Null(agent.Description);
|
||||
Assert.Null(agent.Instructions);
|
||||
Assert.Null(agent.ChatOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains unit tests for the ChatClientExtensions class.
|
||||
/// </summary>
|
||||
public sealed class ChatClientExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithBasicParameters_CreatesAgent()
|
||||
{
|
||||
// Arrange
|
||||
var chatClientMock = new Mock<IChatClient>();
|
||||
|
||||
// Act
|
||||
var agent = chatClientMock.Object.AsAIAgent(
|
||||
instructions: "Test instructions",
|
||||
name: "TestAgent",
|
||||
description: "Test description"
|
||||
);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("TestAgent", agent.Name);
|
||||
Assert.Equal("Test description", agent.Description);
|
||||
Assert.Equal("Test instructions", agent.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithTools_SetsToolsInOptions()
|
||||
{
|
||||
// Arrange
|
||||
var chatClientMock = new Mock<IChatClient>();
|
||||
var tools = new List<AITool> { new Mock<AITool>().Object };
|
||||
|
||||
// Act
|
||||
var agent = chatClientMock.Object.AsAIAgent(tools: tools);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.NotNull(agent.ChatOptions);
|
||||
Assert.Equal(tools, agent.ChatOptions.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithOptions_CreatesAgentWithOptions()
|
||||
{
|
||||
// Arrange
|
||||
var chatClientMock = new Mock<IChatClient>();
|
||||
var options = new ChatClientAgentOptions
|
||||
{
|
||||
Name = "AgentWithOptions",
|
||||
Description = "Desc",
|
||||
ChatOptions = new() { Instructions = "Instr" },
|
||||
UseProvidedChatClientAsIs = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = chatClientMock.Object.AsAIAgent(options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("AgentWithOptions", agent.Name);
|
||||
Assert.Equal("Desc", agent.Description);
|
||||
Assert.Equal("Instr", agent.Instructions);
|
||||
Assert.Same(chatClientMock.Object, agent.ChatClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithNullClient_Throws()
|
||||
{
|
||||
// Arrange
|
||||
IChatClient chatClient = null!;
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => chatClient.AsAIAgent(instructions: "instructions"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithNullClientAndOptions_Throws()
|
||||
{
|
||||
// Arrange
|
||||
IChatClient chatClient = null!;
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => chatClient.AsAIAgent(options: new() { ChatOptions = new() { Instructions = "instructions" } }));
|
||||
}
|
||||
}
|
||||
+493
@@ -0,0 +1,493 @@
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="MessageInjectingChatClient"/>.
|
||||
/// </summary>
|
||||
public class MessageInjectingChatClientTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="MessageInjectingChatClient"/> is resolvable via GetService when the decorator is active.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetService_ReturnsMessageInjectingChatClient_WhenDecoratorActive()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
EnableMessageInjection = true,
|
||||
});
|
||||
|
||||
// Act
|
||||
var injector = agent.ChatClient.GetService<MessageInjectingChatClient>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(injector);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="MessageInjectingChatClient"/> is null when the decorator is not active.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetService_ReturnsNull_WhenDecoratorNotActive()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
ChatClientAgent agent = new(mockService.Object, options: new());
|
||||
|
||||
// Act
|
||||
var injector = agent.ChatClient.GetService<MessageInjectingChatClient>();
|
||||
|
||||
// Assert
|
||||
Assert.Null(injector);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that messages enqueued on the session before RunAsync are included in the service call messages.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesInjectedMessages_WhenEnqueuedBeforeCallAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> capturedMessages = [];
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback((IEnumerable<ChatMessage> msgs, ChatOptions? _, CancellationToken _) =>
|
||||
capturedMessages.AddRange(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 _) =>
|
||||
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,
|
||||
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<ChatMessage>();
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the queue is drained after a call (messages are not re-delivered on subsequent calls).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_DrainsQueue_MessagesNotRedeliveredAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> capturedMessages = [];
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback((IEnumerable<ChatMessage> msgs, ChatOptions? _, CancellationToken _) =>
|
||||
capturedMessages.AddRange(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 _) =>
|
||||
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,
|
||||
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<ChatMessage>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the internal loop fires when no actionable FunctionCallContent is returned
|
||||
/// but there are pending injected messages in the queue.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_LoopsInternally_WhenNoActionableFCCButPendingMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
int serviceCallCount = 0;
|
||||
Mock<IChatClient> mockService = new();
|
||||
MessageInjectingChatClient? injectorRef = null;
|
||||
ChatClientAgentSession? sessionRef = null;
|
||||
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns((IEnumerable<ChatMessage> 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<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,
|
||||
RequirePerServiceCallChatHistoryPersistence = true,
|
||||
EnableMessageInjection = true,
|
||||
});
|
||||
|
||||
injectorRef = agent.ChatClient.GetService<MessageInjectingChatClient>()!;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the internal loop does NOT fire when the response contains actionable
|
||||
/// FunctionCallContent, even if there are pending injected messages.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_DoesNotLoopInternally_WhenActionableFCCPresentAsync()
|
||||
{
|
||||
// Arrange
|
||||
int serviceCallCount = 0;
|
||||
Mock<IChatClient> mockService = new();
|
||||
MessageInjectingChatClient? injectorRef = null;
|
||||
ChatClientAgentSession? sessionRef = null;
|
||||
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns((IEnumerable<ChatMessage> 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<string, object?>())])]));
|
||||
}
|
||||
|
||||
// Subsequent calls return plain text (the FCC loop will call back after tool execution)
|
||||
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final")]));
|
||||
});
|
||||
|
||||
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());
|
||||
|
||||
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<MessageInjectingChatClient>()!;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the internal loop fires when the response contains only InformationalOnly
|
||||
/// FunctionCallContent (which are not actionable) and there are pending injected messages.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_LoopsInternally_WhenOnlyInformationalOnlyFCCAndPendingMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
int serviceCallCount = 0;
|
||||
Mock<IChatClient> mockService = new();
|
||||
MessageInjectingChatClient? injectorRef = null;
|
||||
ChatClientAgentSession? sessionRef = null;
|
||||
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns((IEnumerable<ChatMessage> 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<string, object?>()) { InformationalOnly = true }])]));
|
||||
}
|
||||
|
||||
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final")]));
|
||||
});
|
||||
|
||||
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,
|
||||
RequirePerServiceCallChatHistoryPersistence = true,
|
||||
EnableMessageInjection = true,
|
||||
});
|
||||
|
||||
injectorRef = agent.ChatClient.GetService<MessageInjectingChatClient>()!;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the inner client returns a ConversationId on the first call, the
|
||||
/// MessageInjectingChatClient propagates it to options on subsequent loop iterations.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_PropagatesConversationId_AcrossInternalLoopIterationsAsync()
|
||||
{
|
||||
// Arrange
|
||||
int serviceCallCount = 0;
|
||||
List<string?> capturedConversationIds = [];
|
||||
MessageInjectingChatClient? injectorRef = null;
|
||||
ChatClientAgentSession? sessionRef = null;
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns((IEnumerable<ChatMessage> _, 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<MessageInjectingChatClient>()!;
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_DeliversInjectedMessages_AfterSessionSerializationRoundTripAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> capturedMessagesFirstRun = [];
|
||||
List<ChatMessage> capturedMessagesSecondRun = [];
|
||||
int runCount = 0;
|
||||
Mock<IChatClient> mockService = new();
|
||||
MessageInjectingChatClient? injectorRef = null;
|
||||
ChatClientAgentSession? sessionRef = null;
|
||||
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns((IEnumerable<ChatMessage> 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<string, object?>())])]));
|
||||
}
|
||||
|
||||
// Second run (after deserialization) — capture what messages come through
|
||||
capturedMessagesSecondRun.AddRange(msgs);
|
||||
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final 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());
|
||||
|
||||
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<MessageInjectingChatClient>()!;
|
||||
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
+1374
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user