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:
+435
@@ -0,0 +1,435 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the HTTP API with in-memory conversation, response, and agent index storage.
|
||||
/// Tests create a conversation, create a response, wait for completion, then verify the conversation was updated.
|
||||
/// </summary>
|
||||
public sealed class OpenAIHttpApiIntegrationTests : IAsyncDisposable
|
||||
{
|
||||
private WebApplication? _app;
|
||||
private HttpClient? _httpClient;
|
||||
|
||||
[Fact]
|
||||
public async Task CreateConversationAndResponse_NonStreaming_NonBackground_UpdatesConversationWithOutputAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string AgentName = "test-agent";
|
||||
const string Instructions = "You are a helpful assistant.";
|
||||
const string ExpectedResponse = "The capital of France is Paris.";
|
||||
const string UserMessage = "What is the capital of France?";
|
||||
|
||||
HttpClient client = await this.CreateTestServerWithInMemoryStorageAsync(AgentName, Instructions, ExpectedResponse);
|
||||
|
||||
// Act - Create conversation
|
||||
var createConversationRequest = new { metadata = new { agent_id = AgentName } };
|
||||
string createConvJson = JsonSerializer.Serialize(createConversationRequest);
|
||||
HttpResponseMessage createConvResponse = await this.SendPostRequestAsync(client, "/v1/conversations", createConvJson);
|
||||
using var createConvDoc = await this.ParseResponseAsync(createConvResponse);
|
||||
string conversationId = createConvDoc.RootElement.GetProperty("id").GetString()!;
|
||||
|
||||
// Act - Create response (non-streaming, non-background)
|
||||
var createResponseRequest = new
|
||||
{
|
||||
metadata = new { entity_id = AgentName },
|
||||
conversation = conversationId,
|
||||
input = UserMessage,
|
||||
stream = false
|
||||
};
|
||||
string createRespJson = JsonSerializer.Serialize(createResponseRequest);
|
||||
HttpResponseMessage createRespResponse = await this.SendPostRequestAsync(client, $"/{AgentName}/v1/responses", createRespJson);
|
||||
using var createRespDoc = await this.ParseResponseAsync(createRespResponse);
|
||||
var response = createRespDoc.RootElement;
|
||||
|
||||
// Assert - Response completed
|
||||
Assert.Equal("completed", response.GetProperty("status").GetString());
|
||||
string responseId = response.GetProperty("id").GetString()!;
|
||||
Assert.NotNull(responseId);
|
||||
Assert.StartsWith("resp_", responseId);
|
||||
|
||||
// Assert - Response has output
|
||||
Assert.True(response.TryGetProperty("output", out var output));
|
||||
Assert.True(output.GetArrayLength() > 0);
|
||||
var outputItem = output[0];
|
||||
var content = outputItem.GetProperty("content");
|
||||
Assert.True(content.GetArrayLength() > 0);
|
||||
var textContent = content[0];
|
||||
Assert.Equal("output_text", textContent.GetProperty("type").GetString());
|
||||
Assert.Equal(ExpectedResponse, textContent.GetProperty("text").GetString());
|
||||
|
||||
// Act - List conversation items to verify they were updated
|
||||
HttpResponseMessage listItemsResponse = await this.SendGetRequestAsync(client, $"/v1/conversations/{conversationId}/items");
|
||||
using var listItemsDoc = await this.ParseResponseAsync(listItemsResponse);
|
||||
var itemsList = listItemsDoc.RootElement;
|
||||
|
||||
// Assert - Conversation items were added
|
||||
Assert.Equal("list", itemsList.GetProperty("object").GetString());
|
||||
var items = itemsList.GetProperty("data");
|
||||
|
||||
Assert.True(items.GetArrayLength() > 0, "Conversation should have items after response completion");
|
||||
|
||||
// Find the assistant message in the items
|
||||
bool foundAssistantMessage = items.EnumerateArray()
|
||||
.Where(item => item.GetProperty("type").GetString() == "message" &&
|
||||
item.GetProperty("role").GetString() == "assistant")
|
||||
.Any(item =>
|
||||
{
|
||||
JsonElement itemContent = item.GetProperty("content");
|
||||
if (itemContent.GetArrayLength() > 0)
|
||||
{
|
||||
JsonElement firstContent = itemContent[0];
|
||||
return firstContent.GetProperty("type").GetString() == "output_text" &&
|
||||
firstContent.GetProperty("text").GetString() == ExpectedResponse;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
Assert.True(foundAssistantMessage, "Conversation should contain the assistant's response message");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateConversationAndResponse_Streaming_NonBackground_UpdatesConversationWithOutputAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string AgentName = "streaming-agent";
|
||||
const string Instructions = "You are a helpful assistant.";
|
||||
const string ExpectedResponse = "Hello there! How can I help you today?";
|
||||
const string UserMessage = "Hello";
|
||||
|
||||
HttpClient client = await this.CreateTestServerWithInMemoryStorageAsync(AgentName, Instructions, ExpectedResponse);
|
||||
|
||||
// Act - Create conversation
|
||||
var createConversationRequest = new { metadata = new { agent_id = AgentName } };
|
||||
string createConvJson = JsonSerializer.Serialize(createConversationRequest);
|
||||
HttpResponseMessage createConvResponse = await this.SendPostRequestAsync(client, "/v1/conversations", createConvJson);
|
||||
using var createConvDoc = await this.ParseResponseAsync(createConvResponse);
|
||||
string conversationId = createConvDoc.RootElement.GetProperty("id").GetString()!;
|
||||
|
||||
// Act - Create response (streaming, non-background)
|
||||
var createResponseRequest = new
|
||||
{
|
||||
metadata = new { entity_id = AgentName },
|
||||
conversation = conversationId,
|
||||
input = UserMessage,
|
||||
stream = true
|
||||
};
|
||||
string createRespJson = JsonSerializer.Serialize(createResponseRequest);
|
||||
HttpResponseMessage createRespResponse = await this.SendPostRequestAsync(client, $"/{AgentName}/v1/responses", createRespJson);
|
||||
|
||||
// Assert - Response is SSE format
|
||||
Assert.Equal("text/event-stream", createRespResponse.Content.Headers.ContentType?.MediaType);
|
||||
|
||||
// Parse SSE events
|
||||
string sseContent = await createRespResponse.Content.ReadAsStringAsync();
|
||||
var events = this.ParseSseEvents(sseContent);
|
||||
|
||||
// Assert - Has expected event types
|
||||
var eventTypes = events.Select(e => e.GetProperty("type").GetString()).ToList();
|
||||
Assert.Contains("response.created", eventTypes);
|
||||
Assert.Contains("response.completed", eventTypes);
|
||||
|
||||
// Collect the full response text from deltas
|
||||
var deltaEvents = events.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList();
|
||||
string streamedText = string.Concat(deltaEvents.Select(e => e.GetProperty("delta").GetString()));
|
||||
Assert.Equal(ExpectedResponse, streamedText);
|
||||
|
||||
// Act - List conversation items to verify messages were added
|
||||
HttpResponseMessage listItemsResponse = await this.SendGetRequestAsync(client, $"/v1/conversations/{conversationId}/items");
|
||||
using var listItemsDoc = await this.ParseResponseAsync(listItemsResponse);
|
||||
var itemsList = listItemsDoc.RootElement;
|
||||
|
||||
// Assert - Conversation items were added
|
||||
var items = itemsList.GetProperty("data");
|
||||
Assert.True(items.GetArrayLength() > 0, "Conversation should have items after streaming response completion");
|
||||
|
||||
// Find the assistant message in the items
|
||||
bool foundAssistantMessage = items.EnumerateArray()
|
||||
.Where(item => item.GetProperty("type").GetString() == "message" &&
|
||||
item.GetProperty("role").GetString() == "assistant")
|
||||
.Any(item =>
|
||||
{
|
||||
JsonElement itemContent = item.GetProperty("content");
|
||||
if (itemContent.GetArrayLength() > 0)
|
||||
{
|
||||
JsonElement firstContent = itemContent[0];
|
||||
return firstContent.GetProperty("type").GetString() == "output_text" &&
|
||||
firstContent.GetProperty("text").GetString() == ExpectedResponse;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
Assert.True(foundAssistantMessage, "Conversation should contain the assistant's response message");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateConversationAndResponse_NonStreaming_Background_UpdatesConversationWhenCompleteAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string AgentName = "background-agent";
|
||||
const string Instructions = "You are a helpful assistant.";
|
||||
const string ExpectedResponse = "Processing in background...";
|
||||
const string UserMessage = "Can you process this?";
|
||||
|
||||
HttpClient client = await this.CreateTestServerWithInMemoryStorageAsync(AgentName, Instructions, ExpectedResponse);
|
||||
|
||||
// Act - Create conversation
|
||||
var createConversationRequest = new { metadata = new { agent_id = AgentName } };
|
||||
string createConvJson = JsonSerializer.Serialize(createConversationRequest);
|
||||
HttpResponseMessage createConvResponse = await this.SendPostRequestAsync(client, "/v1/conversations", createConvJson);
|
||||
using var createConvDoc = await this.ParseResponseAsync(createConvResponse);
|
||||
string conversationId = createConvDoc.RootElement.GetProperty("id").GetString()!;
|
||||
|
||||
// Act - Create response (non-streaming, background)
|
||||
var createResponseRequest = new
|
||||
{
|
||||
metadata = new { entity_id = AgentName },
|
||||
conversation = conversationId,
|
||||
input = UserMessage,
|
||||
stream = false,
|
||||
background = true
|
||||
};
|
||||
string createRespJson = JsonSerializer.Serialize(createResponseRequest);
|
||||
HttpResponseMessage createRespResponse = await this.SendPostRequestAsync(client, $"/{AgentName}/v1/responses", createRespJson);
|
||||
using var createRespDoc = await this.ParseResponseAsync(createRespResponse);
|
||||
var response = createRespDoc.RootElement;
|
||||
|
||||
// Assert - Response is in progress or queued
|
||||
string status = response.GetProperty("status").GetString()!;
|
||||
Assert.True(status is "in_progress" or "queued" or "completed", $"Expected 'in_progress', 'queued', or 'completed', got '{status}'");
|
||||
string responseId = response.GetProperty("id").GetString()!;
|
||||
|
||||
// Wait for completion by polling
|
||||
const int MaxAttempts = 20;
|
||||
int attempt = 0;
|
||||
string finalStatus = status;
|
||||
string? errorMessage = null;
|
||||
while (finalStatus != "completed" && finalStatus != "failed" && attempt < MaxAttempts)
|
||||
{
|
||||
await Task.Delay(100);
|
||||
HttpResponseMessage getResponseResponse = await this.SendGetRequestAsync(client, $"/{AgentName}/v1/responses/{responseId}");
|
||||
using var getRespDoc = await this.ParseResponseAsync(getResponseResponse);
|
||||
finalStatus = getRespDoc.RootElement.GetProperty("status").GetString()!;
|
||||
if (getRespDoc.RootElement.TryGetProperty("error", out var error) &&
|
||||
error.ValueKind == JsonValueKind.Object &&
|
||||
error.TryGetProperty("message", out var messageElement))
|
||||
{
|
||||
errorMessage = messageElement.GetString();
|
||||
}
|
||||
|
||||
attempt++;
|
||||
}
|
||||
|
||||
// Assert - Response eventually completed
|
||||
Assert.Equal("completed", finalStatus + (errorMessage != null ? $" Error: {errorMessage}" : ""));
|
||||
|
||||
// Act - List conversation items to verify messages were added
|
||||
HttpResponseMessage listItemsResponse = await this.SendGetRequestAsync(client, $"/v1/conversations/{conversationId}/items");
|
||||
using var listItemsDoc = await this.ParseResponseAsync(listItemsResponse);
|
||||
var itemsList = listItemsDoc.RootElement;
|
||||
|
||||
// Assert - Conversation items were added
|
||||
var items = itemsList.GetProperty("data");
|
||||
Assert.True(items.GetArrayLength() > 0, "Conversation should have items after background response completion");
|
||||
|
||||
// Find the assistant message in the items
|
||||
bool foundAssistantMessage = items.EnumerateArray()
|
||||
.Where(item => item.GetProperty("type").GetString() == "message" &&
|
||||
item.GetProperty("role").GetString() == "assistant")
|
||||
.Any(item =>
|
||||
{
|
||||
JsonElement itemContent = item.GetProperty("content");
|
||||
if (itemContent.GetArrayLength() > 0)
|
||||
{
|
||||
JsonElement firstContent = itemContent[0];
|
||||
return firstContent.GetProperty("type").GetString() == "output_text" &&
|
||||
firstContent.GetProperty("text").GetString() == ExpectedResponse;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
Assert.True(foundAssistantMessage, "Conversation should contain the assistant's response message");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateConversationAndResponse_Streaming_Background_UpdatesConversationWhenCompleteAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string AgentName = "streaming-background-agent";
|
||||
const string Instructions = "You are a helpful assistant.";
|
||||
const string ExpectedResponse = "Streaming background response";
|
||||
const string UserMessage = "Process this with streaming";
|
||||
|
||||
HttpClient client = await this.CreateTestServerWithInMemoryStorageAsync(AgentName, Instructions, ExpectedResponse);
|
||||
|
||||
// Act - Create conversation
|
||||
var createConversationRequest = new { metadata = new { agent_id = AgentName } };
|
||||
string createConvJson = JsonSerializer.Serialize(createConversationRequest);
|
||||
HttpResponseMessage createConvResponse = await this.SendPostRequestAsync(client, "/v1/conversations", createConvJson);
|
||||
using var createConvDoc = await this.ParseResponseAsync(createConvResponse);
|
||||
string conversationId = createConvDoc.RootElement.GetProperty("id").GetString()!;
|
||||
|
||||
// Act - Create response (streaming, background)
|
||||
var createResponseRequest = new
|
||||
{
|
||||
model = AgentName,
|
||||
conversation = conversationId,
|
||||
input = UserMessage,
|
||||
stream = true,
|
||||
background = false // Note: streaming with background=true is typically streaming
|
||||
};
|
||||
string createRespJson = JsonSerializer.Serialize(createResponseRequest);
|
||||
HttpResponseMessage createRespResponse = await this.SendPostRequestAsync(client, $"/{AgentName}/v1/responses", createRespJson);
|
||||
|
||||
// Assert - Response is SSE format
|
||||
Assert.Equal("text/event-stream", createRespResponse.Content.Headers.ContentType?.MediaType);
|
||||
|
||||
// Parse SSE events
|
||||
string sseContent = await createRespResponse.Content.ReadAsStringAsync();
|
||||
var events = this.ParseSseEvents(sseContent);
|
||||
var eventTypes = events.Select(e => e.GetProperty("type").GetString()).ToList();
|
||||
Assert.Contains("response.created", eventTypes);
|
||||
Assert.Contains("response.completed", eventTypes);
|
||||
|
||||
// Act - List conversation items to verify messages were added
|
||||
HttpResponseMessage listItemsResponse = await this.SendGetRequestAsync(client, $"/v1/conversations/{conversationId}/items");
|
||||
using var listItemsDoc = await this.ParseResponseAsync(listItemsResponse);
|
||||
var itemsList = listItemsDoc.RootElement;
|
||||
|
||||
// Assert - Conversation items were added
|
||||
var items = itemsList.GetProperty("data");
|
||||
Assert.True(items.GetArrayLength() > 0, "Conversation should have items after streaming response completion");
|
||||
|
||||
// Find the assistant message in the items
|
||||
bool foundAssistantMessage = items.EnumerateArray()
|
||||
.Where(item => item.GetProperty("type").GetString() == "message" &&
|
||||
item.GetProperty("role").GetString() == "assistant")
|
||||
.Any(item =>
|
||||
{
|
||||
JsonElement itemContent = item.GetProperty("content");
|
||||
if (itemContent.GetArrayLength() > 0)
|
||||
{
|
||||
JsonElement firstContent = itemContent[0];
|
||||
return firstContent.GetProperty("type").GetString() == "output_text";
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
Assert.True(foundAssistantMessage, "Conversation should contain the assistant's response message");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a test server with in-memory conversation, response, and agent index storage.
|
||||
/// </summary>
|
||||
private async Task<HttpClient> CreateTestServerWithInMemoryStorageAsync(string agentName, string instructions, string responseText)
|
||||
{
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
// Create mock chat client
|
||||
IChatClient mockChatClient = new TestHelpers.SimpleMockChatClient(responseText);
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
|
||||
// Add agent
|
||||
builder.AddAIAgent(agentName, instructions, chatClientServiceKey: "chat-client");
|
||||
|
||||
// Add in-memory storage for conversations, responses, and agent index
|
||||
builder.AddOpenAIConversations();
|
||||
builder.AddOpenAIResponses();
|
||||
|
||||
this._app = builder.Build();
|
||||
|
||||
// Map endpoints
|
||||
AIAgent agent = this._app.Services.GetRequiredKeyedService<AIAgent>(agentName);
|
||||
this._app.MapOpenAIConversations();
|
||||
this._app.MapOpenAIResponses(agent);
|
||||
|
||||
await this._app.StartAsync();
|
||||
|
||||
TestServer testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
|
||||
?? throw new InvalidOperationException("TestServer not found");
|
||||
|
||||
this._httpClient = testServer.CreateClient();
|
||||
return this._httpClient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a POST request with JSON content to the test server.
|
||||
/// </summary>
|
||||
private async Task<HttpResponseMessage> SendPostRequestAsync(HttpClient client, string path, string requestJson)
|
||||
{
|
||||
using StringContent content = new(requestJson, Encoding.UTF8, "application/json");
|
||||
return await client.PostAsync(new Uri(path, UriKind.Relative), content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a GET request to the test server.
|
||||
/// </summary>
|
||||
private async Task<HttpResponseMessage> SendGetRequestAsync(HttpClient client, string path)
|
||||
{
|
||||
return await client.GetAsync(new Uri(path, UriKind.Relative));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the response JSON and returns a JsonDocument.
|
||||
/// </summary>
|
||||
private async Task<JsonDocument> ParseResponseAsync(HttpResponseMessage response)
|
||||
{
|
||||
string responseJson = await response.Content.ReadAsStringAsync();
|
||||
return JsonDocument.Parse(responseJson);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses SSE events from streaming response content string.
|
||||
/// </summary>
|
||||
private JsonElement[] ParseSseEvents(string sseContent)
|
||||
{
|
||||
var events = new System.Collections.Generic.List<JsonElement>();
|
||||
var lines = sseContent.Split('\n');
|
||||
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i].TrimEnd('\r');
|
||||
|
||||
if (line.StartsWith("event: ", StringComparison.Ordinal) && i + 1 < lines.Length)
|
||||
{
|
||||
var dataLine = lines[i + 1].TrimEnd('\r');
|
||||
if (dataLine.StartsWith("data: ", StringComparison.Ordinal))
|
||||
{
|
||||
var jsonData = dataLine.Substring("data: ".Length);
|
||||
if (!string.IsNullOrWhiteSpace(jsonData))
|
||||
{
|
||||
var doc = JsonDocument.Parse(jsonData);
|
||||
events.Add(doc.RootElement.Clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return events.ToArray();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
this._httpClient?.Dispose();
|
||||
if (this._app != null)
|
||||
{
|
||||
await this._app.DisposeAsync();
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user