diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs index 8b909651b9..e3706bee1c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs @@ -36,7 +36,13 @@ public async IAsyncEnumerable ExecuteAsync( // Create options with properties from the request var chatOptions = new ChatOptions { - ConversationId = request.Conversation?.Id, + // Note: We intentionally do NOT set ConversationId on ChatOptions here. + // The conversation ID from the client request is used by the hosting layer + // to manage conversation storage, but should not be forwarded to the underlying + // IChatClient as it has its own concept of conversations (or none at all). + // --- + // ConversationId = request.Conversation?.Id, + Temperature = (float?)request.Temperature, TopP = (float?)request.TopP, MaxOutputTokens = request.MaxOutputTokens, diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs index 01e7c60137..dd9a048ea8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs @@ -82,9 +82,16 @@ public async IAsyncEnumerable ExecuteAsync( { string agentName = GetAgentName(request)!; AIAgent agent = this._serviceProvider.GetRequiredKeyedService(agentName); + var chatOptions = new ChatOptions { - ConversationId = request.Conversation?.Id, + // Note: We intentionally do NOT set ConversationId on ChatOptions here. + // The conversation ID from the client request is used by the hosting layer + // to manage conversation storage, but should not be forwarded to the underlying + // IChatClient as it has its own concept of conversations (or none at all). + // --- + // ConversationId = request.Conversation?.Id, + Temperature = (float?)request.Temperature, TopP = (float?)request.TopP, MaxOutputTokens = request.MaxOutputTokens, diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs index 8304bb8a56..abf66a732f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs @@ -1091,6 +1091,116 @@ public async Task CreateResponseStreaming_ContentPartAdded_IncludesEventAsync() Assert.Contains(updates, u => u is StreamingResponseContentPartAddedUpdate); } + /// + /// Verifies that when a client provides a conversation ID, the underlying IChatClient + /// does NOT receive that conversation ID via ChatOptions.ConversationId. + /// This ensures that the host's conversation management is separate from the IChatClient's + /// conversation handling (if any). + /// + [Fact] + public async Task CreateResponse_WithConversationId_DoesNotForwardConversationIdToIChatClientAsync() + { + // Arrange + const string AgentName = "conversation-id-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Response"; + + this._httpClient = await this.CreateTestServerWithConversationsAsync(AgentName, Instructions, ExpectedResponse); + var mockChatClient = this.ResolveMockChatClient(); + + // First, create a conversation + var createConversationRequest = new { metadata = new { agent_id = AgentName } }; + string createConvJson = System.Text.Json.JsonSerializer.Serialize(createConversationRequest); + using StringContent createConvContent = new(createConvJson, Encoding.UTF8, "application/json"); + HttpResponseMessage createConvResponse = await this._httpClient.PostAsync( + new Uri("/v1/conversations", UriKind.Relative), + createConvContent); + Assert.True(createConvResponse.IsSuccessStatusCode, $"Create conversation failed: {createConvResponse.StatusCode}"); + + string convResponseJson = await createConvResponse.Content.ReadAsStringAsync(); + using var convDoc = System.Text.Json.JsonDocument.Parse(convResponseJson); + string conversationId = convDoc.RootElement.GetProperty("id").GetString()!; + + // Act - Send request with conversation ID using raw HTTP + // (OpenAI SDK doesn't expose ConversationId directly on ResponseCreationOptions) + var requestBody = new + { + input = "Test", + agent = new { name = AgentName }, + conversation = conversationId, + stream = false + }; + string requestJson = System.Text.Json.JsonSerializer.Serialize(requestBody); + using StringContent content = new(requestJson, Encoding.UTF8, "application/json"); + HttpResponseMessage httpResponse = await this._httpClient.PostAsync( + new Uri($"/{AgentName}/v1/responses", UriKind.Relative), + content); + + // Assert - Response is successful + Assert.True(httpResponse.IsSuccessStatusCode, $"Response status: {httpResponse.StatusCode}"); + + // Assert - The IChatClient should have received ChatOptions, but without the ConversationId set + Assert.NotNull(mockChatClient.LastChatOptions); + Assert.Null(mockChatClient.LastChatOptions.ConversationId); + } + + /// + /// Verifies that when a client provides a conversation ID in streaming mode, the underlying + /// IChatClient does NOT receive that conversation ID via ChatOptions.ConversationId. + /// + [Fact] + public async Task CreateResponseStreaming_WithConversationId_DoesNotForwardConversationIdToIChatClientAsync() + { + // Arrange + const string AgentName = "conversation-streaming-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Streaming response"; + + this._httpClient = await this.CreateTestServerWithConversationsAsync(AgentName, Instructions, ExpectedResponse); + var mockChatClient = this.ResolveMockChatClient(); + + // First, create a conversation + var createConversationRequest = new { metadata = new { agent_id = AgentName } }; + string createConvJson = System.Text.Json.JsonSerializer.Serialize(createConversationRequest); + using StringContent createConvContent = new(createConvJson, Encoding.UTF8, "application/json"); + HttpResponseMessage createConvResponse = await this._httpClient.PostAsync( + new Uri("/v1/conversations", UriKind.Relative), + createConvContent); + Assert.True(createConvResponse.IsSuccessStatusCode, $"Create conversation failed: {createConvResponse.StatusCode}"); + + string convResponseJson = await createConvResponse.Content.ReadAsStringAsync(); + using var convDoc = System.Text.Json.JsonDocument.Parse(convResponseJson); + string conversationId = convDoc.RootElement.GetProperty("id").GetString()!; + + // Act - Send streaming request with conversation ID using raw HTTP + var requestBody = new + { + input = "Test", + agent = new { name = AgentName }, + conversation = conversationId, + stream = true + }; + string requestJson = System.Text.Json.JsonSerializer.Serialize(requestBody); + using StringContent content = new(requestJson, Encoding.UTF8, "application/json"); + HttpResponseMessage httpResponse = await this._httpClient.PostAsync( + new Uri($"/{AgentName}/v1/responses", UriKind.Relative), + content); + + // Assert - Response is successful and is SSE + Assert.True(httpResponse.IsSuccessStatusCode, $"Response status: {httpResponse.StatusCode}"); + Assert.Equal("text/event-stream", httpResponse.Content.Headers.ContentType?.MediaType); + + // Consume the SSE stream to complete the request + string sseContent = await httpResponse.Content.ReadAsStringAsync(); + + // Verify streaming completed successfully by checking for response.completed event + Assert.Contains("response.completed", sseContent); + + // Assert - The IChatClient should have received ChatOptions, but without the ConversationId set + Assert.NotNull(mockChatClient.LastChatOptions); + Assert.Null(mockChatClient.LastChatOptions.ConversationId); + } + private OpenAIResponseClient CreateResponseClient(string agentName) { return new OpenAIResponseClient( @@ -1103,6 +1213,19 @@ private OpenAIResponseClient CreateResponseClient(string agentName) }); } + private TestHelpers.SimpleMockChatClient ResolveMockChatClient() + { + ArgumentNullException.ThrowIfNull(this._app, nameof(this._app)); + + var chatClient = this._app.Services.GetRequiredKeyedService("chat-client"); + if (chatClient is not TestHelpers.SimpleMockChatClient mockChatClient) + { + throw new InvalidOperationException("Mock chat client not found or of incorrect type."); + } + + return mockChatClient; + } + private async Task CreateTestServerAsync(string agentName, string instructions, string responseText = "Test response") { WebApplicationBuilder builder = WebApplication.CreateBuilder(); @@ -1125,6 +1248,30 @@ private async Task CreateTestServerAsync(string agentName, string in return testServer.CreateClient(); } + private async Task CreateTestServerWithConversationsAsync(string agentName, string instructions, string responseText = "Test response") + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + IChatClient mockChatClient = new TestHelpers.SimpleMockChatClient(responseText); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + builder.AddOpenAIResponses(); + builder.AddOpenAIConversations(); + builder.AddAIAgent(agentName, instructions, chatClientServiceKey: "chat-client"); + + this._app = builder.Build(); + AIAgent agent = this._app.Services.GetRequiredKeyedService(agentName); + this._app.MapOpenAIResponses(agent); + this._app.MapOpenAIConversations(); + + await this._app.StartAsync(); + + TestServer testServer = this._app.Services.GetRequiredService() as TestServer + ?? throw new InvalidOperationException("TestServer not found"); + + return testServer.CreateClient(); + } + private async Task CreateTestServerWithCustomClientAsync(string agentName, string instructions, IChatClient chatClient) { WebApplicationBuilder builder = WebApplication.CreateBuilder(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/TestHelpers.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/TestHelpers.cs index c3054e0296..191da528a4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/TestHelpers.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/TestHelpers.cs @@ -19,6 +19,8 @@ internal sealed class SimpleMockChatClient : IChatClient { private readonly string _responseText; + public ChatOptions? LastChatOptions { get; private set; } + public SimpleMockChatClient(string responseText = "Test response") { this._responseText = responseText; @@ -31,6 +33,11 @@ public Task GetResponseAsync( ChatOptions? options = null, CancellationToken cancellationToken = default) { + if (options is not null) + { + this.LastChatOptions = options; + } + // Count input messages to simulate context size int messageCount = messages.Count(); ChatMessage message = new(ChatRole.Assistant, this._responseText); @@ -53,6 +60,11 @@ public async IAsyncEnumerable GetStreamingResponseAsync( ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { + if (options is not null) + { + this.LastChatOptions = options; + } + await Task.Delay(1, cancellationToken); // Count input messages to simulate context size