diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs index 4ceff757431..642c0da2034 100644 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs @@ -120,9 +120,24 @@ public static IChatClient AsIChatClientWithStoredOutputDisabled(this ResponsesCl return Throw.IfNull(responseClient) .AsIChatClient(model) .AsBuilder() - .ConfigureOptions(x => x.RawRepresentationFactory = _ => includeReasoningEncryptedContent - ? new CreateResponseOptions() { StoredOutputEnabled = false, IncludedProperties = { IncludedResponseProperty.ReasoningEncryptedContent } } - : new CreateResponseOptions() { StoredOutputEnabled = false }) + .ConfigureOptions(x => + { + var previousFactory = x.RawRepresentationFactory; + x.RawRepresentationFactory = state => + { + var responseOptions = previousFactory?.Invoke(state) as CreateResponseOptions ?? new CreateResponseOptions(); + + responseOptions.StoredOutputEnabled = false; + + if (includeReasoningEncryptedContent && + !responseOptions.IncludedProperties.Contains(IncludedResponseProperty.ReasoningEncryptedContent)) + { + responseOptions.IncludedProperties.Add(IncludedResponseProperty.ReasoningEncryptedContent); + } + + return responseOptions; + }; + }) .Build(); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ProjectResponsesClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ProjectResponsesClientExtensionsTests.cs index fda9962e124..b1424bb403b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ProjectResponsesClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ProjectResponsesClientExtensionsTests.cs @@ -129,6 +129,75 @@ public void AsIChatClientWithStoredOutputDisabled_WithIncludeReasoningFalse_Conf Assert.DoesNotContain(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties); } + /// + /// Verify that AsIChatClientWithStoredOutputDisabled preserves an existing RawRepresentationFactory + /// set on ChatOptions, augmenting it with StoredOutputEnabled and ReasoningEncryptedContent + /// rather than replacing it. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_PreservesExistingRawRepresentationFactory() + { + // Arrange + var responseClient = CreateTestClient(); + var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(); + + // Simulate a caller setting their own RawRepresentationFactory on ChatOptions + // (e.g., to add WebSearchCallActionSources). + var options = new ChatOptions + { + RawRepresentationFactory = _ => new CreateResponseOptions + { + IncludedProperties = { IncludedResponseProperty.WebSearchCallActionSources }, + }, + }; + + // Act + var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient, options); + + // Assert + Assert.NotNull(createResponseOptions); + Assert.False(createResponseOptions.StoredOutputEnabled); + Assert.Contains(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties); + Assert.Contains(IncludedResponseProperty.WebSearchCallActionSources, createResponseOptions.IncludedProperties); + } + + /// + /// Verify that AsIChatClientWithStoredOutputDisabled does not duplicate ReasoningEncryptedContent + /// when the existing factory already includes it. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_DoesNotDuplicateReasoningEncryptedContent() + { + // Arrange + var responseClient = CreateTestClient(); + var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(); + + // Simulate a caller that already includes ReasoningEncryptedContent + var options = new ChatOptions + { + RawRepresentationFactory = _ => new CreateResponseOptions + { + IncludedProperties = { IncludedResponseProperty.ReasoningEncryptedContent }, + }, + }; + + // Act + var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient, options); + + // Assert - ReasoningEncryptedContent should appear exactly once + Assert.NotNull(createResponseOptions); + int count = 0; + foreach (var prop in createResponseOptions.IncludedProperties) + { + if (prop == IncludedResponseProperty.ReasoningEncryptedContent) + { + count++; + } + } + + Assert.Equal(1, count); + } + /// /// Verify that AsIChatClientWithStoredOutputDisabled works with an optional deployment name. /// @@ -153,6 +222,15 @@ public void AsIChatClientWithStoredOutputDisabled_WithDeploymentName_ConfiguresS /// by using reflection to access the configure action and invoking it on a test . /// private static CreateResponseOptions? GetCreateResponseOptionsFromPipeline(IChatClient chatClient) + { + return GetCreateResponseOptionsFromPipeline(chatClient, new ChatOptions()); + } + + /// + /// Overload that runs the configure action on caller-supplied , + /// useful for testing that existing factories are preserved. + /// + private static CreateResponseOptions? GetCreateResponseOptionsFromPipeline(IChatClient chatClient, ChatOptions options) { var configureField = chatClient.GetType().GetField("_configureOptions", BindingFlags.NonPublic | BindingFlags.Instance); Assert.NotNull(configureField); @@ -160,7 +238,6 @@ public void AsIChatClientWithStoredOutputDisabled_WithDeploymentName_ConfiguresS var configureAction = configureField.GetValue(chatClient) as Action; Assert.NotNull(configureAction); - var options = new ChatOptions(); configureAction(options); Assert.NotNull(options.RawRepresentationFactory); diff --git a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIResponseClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIResponseClientExtensionsTests.cs index 1205889e199..eede6ec6375 100644 --- a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIResponseClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIResponseClientExtensionsTests.cs @@ -370,6 +370,75 @@ public void AsIChatClientWithStoredOutputDisabled_WithIncludeReasoningFalse_Conf Assert.DoesNotContain(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties); } + /// + /// Verify that AsIChatClientWithStoredOutputDisabled preserves an existing RawRepresentationFactory + /// set on ChatOptions, augmenting it with StoredOutputEnabled and ReasoningEncryptedContent + /// rather than replacing it. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_PreservesExistingRawRepresentationFactory() + { + // Arrange + var responseClient = new TestOpenAIResponseClient(); + var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(); + + // Simulate a caller setting their own RawRepresentationFactory on ChatOptions + // (e.g., to add WebSearchCallActionSources). + var options = new ChatOptions + { + RawRepresentationFactory = _ => new CreateResponseOptions + { + IncludedProperties = { IncludedResponseProperty.WebSearchCallActionSources }, + }, + }; + + // Act + var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient, options); + + // Assert + Assert.NotNull(createResponseOptions); + Assert.False(createResponseOptions.StoredOutputEnabled); + Assert.Contains(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties); + Assert.Contains(IncludedResponseProperty.WebSearchCallActionSources, createResponseOptions.IncludedProperties); + } + + /// + /// Verify that AsIChatClientWithStoredOutputDisabled does not duplicate ReasoningEncryptedContent + /// when the existing factory already includes it. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_DoesNotDuplicateReasoningEncryptedContent() + { + // Arrange + var responseClient = new TestOpenAIResponseClient(); + var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(); + + // Simulate a caller that already includes ReasoningEncryptedContent + var options = new ChatOptions + { + RawRepresentationFactory = _ => new CreateResponseOptions + { + IncludedProperties = { IncludedResponseProperty.ReasoningEncryptedContent }, + }, + }; + + // Act + var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient, options); + + // Assert - ReasoningEncryptedContent should appear exactly once + Assert.NotNull(createResponseOptions); + int count = 0; + foreach (var prop in createResponseOptions.IncludedProperties) + { + if (prop == IncludedResponseProperty.ReasoningEncryptedContent) + { + count++; + } + } + + Assert.Equal(1, count); + } + /// /// A simple test IServiceProvider implementation for testing. /// @@ -394,6 +463,15 @@ private sealed class TestServiceProvider : IServiceProvider /// by using reflection to access the configure action and invoking it on a test . /// private static CreateResponseOptions? GetCreateResponseOptionsFromPipeline(IChatClient chatClient) + { + return GetCreateResponseOptionsFromPipeline(chatClient, new ChatOptions()); + } + + /// + /// Overload that runs the configure action on caller-supplied , + /// useful for testing that existing factories are preserved. + /// + private static CreateResponseOptions? GetCreateResponseOptionsFromPipeline(IChatClient chatClient, ChatOptions options) { // The ConfigureOptionsChatClient stores the configure action in a private field. var configureField = chatClient.GetType().GetField("_configureOptions", BindingFlags.NonPublic | BindingFlags.Instance); @@ -402,7 +480,6 @@ private sealed class TestServiceProvider : IServiceProvider var configureAction = configureField.GetValue(chatClient) as Action; Assert.NotNull(configureAction); - var options = new ChatOptions(); configureAction(options); Assert.NotNull(options.RawRepresentationFactory);