diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index 206f97cf54..4a42241b3c 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs @@ -519,16 +519,16 @@ await thread.AIContextProvider.InvokedAsync(new(inputMessages, aiContextProvider { ChatOptions? requestChatOptions = (runOptions as ChatClientAgentRunOptions)?.ChatOptions?.Clone(); - // If no agent chat options were provided, return the request chat options as is. + // If no agent chat options were provided, return the request chat options with just agent run options overrides. if (this._agentOptions?.ChatOptions is null) { - return GetContinuationTokenAndApplyBackgroundResponsesProperties(requestChatOptions, runOptions); + return ApplyAgentRunOptionsOverrides(requestChatOptions, runOptions); } - // If no request chat options were provided, use the agent's chat options clone. + // If no request chat options were provided, use the agent's chat options clone with agent run options overrides. if (requestChatOptions is null) { - return GetContinuationTokenAndApplyBackgroundResponsesProperties(this._agentOptions?.ChatOptions.Clone(), runOptions); + return ApplyAgentRunOptionsOverrides(this._agentOptions?.ChatOptions.Clone(), runOptions); } // If both are present, we need to merge them. @@ -557,9 +557,9 @@ await thread.AIContextProvider.InvokedAsync(new(inputMessages, aiContextProvider // Merge only the additional properties from the agent if they are not already set in the request options. if (requestChatOptions.AdditionalProperties is not null && this._agentOptions.ChatOptions.AdditionalProperties is not null) { - foreach (var propertyKey in this._agentOptions.ChatOptions.AdditionalProperties.Keys) + foreach (var kvp in this._agentOptions.ChatOptions.AdditionalProperties) { - _ = requestChatOptions.AdditionalProperties.TryAdd(propertyKey, this._agentOptions.ChatOptions.AdditionalProperties[propertyKey]); + _ = requestChatOptions.AdditionalProperties.TryAdd(kvp.Key, kvp.Value); } } else @@ -624,9 +624,9 @@ await thread.AIContextProvider.InvokedAsync(new(inputMessages, aiContextProvider } } - return GetContinuationTokenAndApplyBackgroundResponsesProperties(requestChatOptions, runOptions); + return ApplyAgentRunOptionsOverrides(requestChatOptions, runOptions); - static (ChatOptions?, ChatClientAgentContinuationToken?) GetContinuationTokenAndApplyBackgroundResponsesProperties(ChatOptions? chatOptions, AgentRunOptions? agentRunOptions) + static (ChatOptions?, ChatClientAgentContinuationToken?) ApplyAgentRunOptionsOverrides(ChatOptions? chatOptions, AgentRunOptions? agentRunOptions) { if (agentRunOptions?.AllowBackgroundResponses is not null) { @@ -643,6 +643,17 @@ await thread.AIContextProvider.InvokedAsync(new(inputMessages, aiContextProvider chatOptions.ContinuationToken = agentContinuationToken!.InnerToken; } + // Add/Replace any additional properties from the AgentRunOptions, since they should always take precedence. + if (agentRunOptions?.AdditionalProperties is { Count: > 0 }) + { + chatOptions ??= new ChatOptions(); + chatOptions.AdditionalProperties ??= new(); + foreach (var kvp in agentRunOptions.AdditionalProperties) + { + chatOptions.AdditionalProperties[kvp.Key] = kvp.Value; + } + } + return (chatOptions, agentContinuationToken); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs index ba4e836b07..546dc258cd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs @@ -1120,433 +1120,6 @@ public void ChatOptionsReturnsClonedCopyWhenAgentOptionsHaveChatOptions() #endregion - #region ChatOptions Merging Tests - - /// - /// Verify that ChatOptions merging works when agent has ChatOptions but request doesn't. - /// - [Fact] - public async Task ChatOptionsMergingUsesAgentOptionsWhenRequestHasNoneAsync() - { - // Arrange - var agentChatOptions = new ChatOptions { MaxOutputTokens = 100, Temperature = 0.7f, Instructions = "test instructions" }; - Mock mockService = new(); - ChatOptions? capturedChatOptions = null; - mockService.Setup( - s => s.GetResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .Callback, 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 { 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 mockService = new(); - ChatOptions? capturedChatOptions = null; - mockService.Setup( - s => s.GetResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .Callback, 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 { new(ChatRole.User, "test") }; - - // Act - await agent.RunAsync(messages); - - // Assert - Assert.NotNull(capturedChatOptions); - Assert.Equal("test instructions", capturedChatOptions.Instructions); - } - - /// - /// Verify that ChatOptions merging works when request has ChatOptions but agent doesn't. - /// - [Fact] - public async Task ChatOptionsMergingUsesRequestOptionsWhenAgentHasNoneAsync() - { - // Arrange - var requestChatOptions = new ChatOptions { MaxOutputTokens = 200, Temperature = 0.3f, Instructions = "test instructions" }; - Mock mockService = new(); - ChatOptions? capturedChatOptions = null; - mockService.Setup( - s => s.GetResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => - capturedChatOptions = opts) - .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); - - ChatClientAgent agent = new(mockService.Object); - var messages = new List { 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); - } - - /// - /// Verify that ChatOptions merging prioritizes request options over agent options. - /// - [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 { ["key"] = "agent-value" } - }; - var requestChatOptions = new ChatOptions - { - // TopP and ModelId not set, should use agent values - MaxOutputTokens = 200, - Temperature = 0.3f, - AdditionalProperties = new AdditionalPropertiesDictionary { ["key"] = "request-value" }, - Instructions = "request instructions" - }; - var expectedChatOptionsMerge = new ChatOptions - { - MaxOutputTokens = 200, // Request value takes priority - Temperature = 0.3f, // Request value takes priority - AdditionalProperties = new AdditionalPropertiesDictionary { ["key"] = "request-value" }, // Request value takes priority - 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 mockService = new(); - ChatOptions? capturedChatOptions = null; - mockService.Setup( - s => s.GetResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .Callback, 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 { new(ChatRole.User, "test") }; - - // Act - await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(requestChatOptions)); - - // 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("request-value", capturedChatOptions.AdditionalProperties["key"]); // Request value takes priority - 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 - } - - /// - /// Verify that ChatOptions merging returns null when both agent and request have no ChatOptions. - /// - [Fact] - public async Task ChatOptionsMergingReturnsNullWhenBothAgentAndRequestHaveNoneAsync() - { - // Arrange - Mock mockService = new(); - ChatOptions? capturedChatOptions = null; - mockService.Setup( - s => s.GetResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => - capturedChatOptions = opts) - .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); - - ChatClientAgent agent = new(mockService.Object); - var messages = new List { new(ChatRole.User, "test") }; - - // Act - await agent.RunAsync(messages); - - // Assert - Assert.Null(capturedChatOptions); - } - - /// - /// Verify that ChatOptions merging concatenates Tools from agent and request. - /// - [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 mockService = new(); - ChatOptions? capturedChatOptions = null; - mockService.Setup( - s => s.GetResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .Callback, 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 { 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); - } - - /// - /// Verify that ChatOptions merging uses agent Tools when request has no Tools. - /// - [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 mockService = new(); - ChatOptions? capturedChatOptions = null; - mockService.Setup( - s => s.GetResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .Callback, 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 { 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 - } - - /// - /// Verify that ChatOptions merging uses RawRepresentationFactory from request first, with fallback to agent. - /// - [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 mockService = new(); - ChatOptions? capturedChatOptions = null; - mockService.Setup( - s => s.GetResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .Callback, 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 { 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!)); - } - - /// - /// Verify that ChatOptions merging handles all scalar properties correctly. - /// - [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 mockService = new(); - ChatOptions? capturedChatOptions = null; - mockService.Setup( - s => s.GetResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .Callback, 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 { 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); - } - - #endregion - #region GetService Method Tests /// diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatOptionsMergingTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatOptionsMergingTests.cs new file mode 100644 index 0000000000..6dda0f0278 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatOptionsMergingTests.cs @@ -0,0 +1,442 @@ +// 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; + +/// +/// Contains tests for merging in . +/// +public class ChatClientAgent_ChatOptionsMergingTests +{ + /// + /// Verify that ChatOptions merging works when agent has ChatOptions but request doesn't. + /// + [Fact] + public async Task ChatOptionsMergingUsesAgentOptionsWhenRequestHasNoneAsync() + { + // Arrange + var agentChatOptions = new ChatOptions { MaxOutputTokens = 100, Temperature = 0.7f, Instructions = "test instructions" }; + Mock mockService = new(); + ChatOptions? capturedChatOptions = null; + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, 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 { 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 mockService = new(); + ChatOptions? capturedChatOptions = null; + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, 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 { new(ChatRole.User, "test") }; + + // Act + await agent.RunAsync(messages); + + // Assert + Assert.NotNull(capturedChatOptions); + Assert.Equal("test instructions", capturedChatOptions.Instructions); + } + + /// + /// Verify that ChatOptions merging works when request has ChatOptions but agent doesn't. + /// + [Fact] + public async Task ChatOptionsMergingUsesRequestOptionsWhenAgentHasNoneAsync() + { + // Arrange + var requestChatOptions = new ChatOptions { MaxOutputTokens = 200, Temperature = 0.3f, Instructions = "test instructions" }; + Mock mockService = new(); + ChatOptions? capturedChatOptions = null; + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => + capturedChatOptions = opts) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object); + var messages = new List { 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); + } + + /// + /// Verify that merging prioritizes over request and that in turn over agent level . + /// + [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 mockService = new(); + ChatOptions? capturedChatOptions = null; + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, 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 { 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 + } + + /// + /// Verify that ChatOptions merging returns null when both agent and request have no ChatOptions. + /// + [Fact] + public async Task ChatOptionsMergingReturnsNullWhenBothAgentAndRequestHaveNoneAsync() + { + // Arrange + Mock mockService = new(); + ChatOptions? capturedChatOptions = null; + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => + capturedChatOptions = opts) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object); + var messages = new List { new(ChatRole.User, "test") }; + + // Act + await agent.RunAsync(messages); + + // Assert + Assert.Null(capturedChatOptions); + } + + /// + /// Verify that ChatOptions merging concatenates Tools from agent and request. + /// + [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 mockService = new(); + ChatOptions? capturedChatOptions = null; + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, 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 { 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); + } + + /// + /// Verify that ChatOptions merging uses agent Tools when request has no Tools. + /// + [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 mockService = new(); + ChatOptions? capturedChatOptions = null; + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, 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 { 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 + } + + /// + /// Verify that ChatOptions merging uses RawRepresentationFactory from request first, with fallback to agent. + /// + [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 mockService = new(); + ChatOptions? capturedChatOptions = null; + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, 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 { 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!)); + } + + /// + /// Verify that ChatOptions merging handles all scalar properties correctly. + /// + [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 mockService = new(); + ChatOptions? capturedChatOptions = null; + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, 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 { 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); + } +}