Files
agent-framework/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIConversationsConformanceTests.cs
Reuben Bond 33f84f9ed2 .NET: Improve fidelity of OpenAI Responses server and add Conversations (#1907)
* Improve fidelity of OpenAI Responses server and add Conversations

* Merge

* nit

* Undo prior change

* Undo prior change

* Review feedback

* Review feedback

* Fix test

* Use simpler JsonDocument approach for polymorphic deserialization

* More review feedback

* dotnet format
2025-11-05 18:46:19 +00:00

1202 lines
56 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
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>
/// Conformance tests for OpenAI Conversations API implementation behavior.
/// Tests use real API traces to ensure our implementation produces responses
/// that match OpenAI's wire format when processing actual requests through the server.
/// </summary>
public sealed class OpenAIConversationsConformanceTests : IAsyncDisposable
{
private const string TracesBasePath = "ConformanceTraces/Conversations";
private WebApplication? _app;
private HttpClient? _httpClient;
/// <summary>
/// Loads a JSON file from the conformance traces directory.
/// </summary>
private static string LoadTraceFile(string relativePath)
{
var fullPath = Path.Combine(TracesBasePath, relativePath);
if (!File.Exists(fullPath))
{
throw new FileNotFoundException($"Conformance trace file not found: {fullPath}");
}
return File.ReadAllText(fullPath);
}
/// <summary>
/// Loads a JSON document from the conformance traces directory.
/// </summary>
private static JsonDocument LoadTraceDocument(string relativePath)
{
var json = LoadTraceFile(relativePath);
return JsonDocument.Parse(json);
}
/// <summary>
/// Asserts that a JSON element exists (property is present, value can be null).
/// </summary>
private static void AssertJsonPropertyExists(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out _))
{
Assert.Fail($"Expected property '{propertyName}' not found in JSON");
}
}
/// <summary>
/// Asserts that a JSON element has a specific string value.
/// </summary>
private static void AssertJsonPropertyEquals(JsonElement element, string propertyName, string expectedValue)
{
AssertJsonPropertyExists(element, propertyName);
var actualValue = element.GetProperty(propertyName).GetString();
if (actualValue != expectedValue)
{
Assert.Fail($"Property '{propertyName}': expected '{expectedValue}', got '{actualValue}'");
}
}
/// <summary>
/// Asserts that a JSON element has a specific boolean value.
/// </summary>
private static void AssertJsonPropertyEquals(JsonElement element, string propertyName, bool expectedValue)
{
AssertJsonPropertyExists(element, propertyName);
var actualValue = element.GetProperty(propertyName).GetBoolean();
if (actualValue != expectedValue)
{
Assert.Fail($"Property '{propertyName}': expected {expectedValue}, got {actualValue}");
}
}
/// <summary>
/// Creates a test server with Conversations API.
/// </summary>
private async Task<HttpClient> CreateTestServerAsync(string agentName, string instructions, string responseText)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
IChatClient mockChatClient = new TestHelpers.SimpleMockChatClient(responseText);
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent(agentName, instructions, chatClientServiceKey: "chat-client");
builder.AddOpenAIConversations();
builder.AddOpenAIResponses();
this._app = builder.Build();
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>
/// Creates a test server with a stateful mock that returns different responses for each call.
/// </summary>
private async Task<HttpClient> CreateTestServerWithStatefulMockAsync(string agentName, string instructions, string[] responseTexts)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
IChatClient mockChatClient = new TestHelpers.StatefulMockChatClient(responseTexts);
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent(agentName, instructions, chatClientServiceKey: "chat-client");
builder.AddOpenAIConversations();
builder.AddOpenAIResponses();
this._app = builder.Build();
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>
/// Creates a test server with a tool call mock.
/// </summary>
private async Task<HttpClient> CreateTestServerWithToolCallAsync(string agentName, string instructions, string functionName, string arguments)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
IChatClient mockChatClient = new TestHelpers.ToolCallMockChatClient(functionName, arguments);
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent(agentName, instructions, chatClientServiceKey: "chat-client");
builder.AddOpenAIConversations();
builder.AddOpenAIResponses();
this._app = builder.Build();
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 static 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 static async Task<HttpResponseMessage> SendGetRequestAsync(HttpClient client, string path)
{
return await client.GetAsync(new Uri(path, UriKind.Relative));
}
/// <summary>
/// Sends a DELETE request to the test server.
/// </summary>
private static async Task<HttpResponseMessage> SendDeleteRequestAsync(HttpClient client, string path)
{
return await client.DeleteAsync(new Uri(path, UriKind.Relative));
}
/// <summary>
/// Parses the response JSON and returns a JsonDocument.
/// </summary>
private static async Task<JsonDocument> ParseResponseAsync(HttpResponseMessage response)
{
string responseJson = await response.Content.ReadAsStringAsync();
return JsonDocument.Parse(responseJson);
}
[Fact]
public async Task BasicConversationCreateAsync()
{
// Arrange
string requestJson = LoadTraceFile("basic/create_conversation_request.json");
HttpClient client = await this.CreateTestServerAsync("basic-agent", "You are a helpful assistant.", "The capital of France is Paris.");
// Act
HttpResponseMessage httpResponse = await SendPostRequestAsync(client, "/v1/conversations", requestJson);
using var responseDoc = await ParseResponseAsync(httpResponse);
var response = responseDoc.RootElement;
// Parse the request
using var requestDoc = JsonDocument.Parse(requestJson);
var request = requestDoc.RootElement;
// Assert - Request has metadata
AssertJsonPropertyExists(request, "metadata");
var requestMetadata = request.GetProperty("metadata");
Assert.Equal(JsonValueKind.Object, requestMetadata.ValueKind);
// Assert - Response metadata
AssertJsonPropertyExists(response, "id");
AssertJsonPropertyEquals(response, "object", "conversation");
AssertJsonPropertyExists(response, "created_at");
var id = response.GetProperty("id").GetString();
Assert.NotNull(id);
Assert.StartsWith("conv_", id);
var createdAt = response.GetProperty("created_at").GetInt64();
Assert.True(createdAt > 0, "created_at should be a positive unix timestamp");
// Assert - Response preserves metadata
AssertJsonPropertyExists(response, "metadata");
var responseMetadata = response.GetProperty("metadata");
Assert.Equal(JsonValueKind.Object, responseMetadata.ValueKind);
}
[Fact]
public async Task BasicConversationWithMessagesAsync()
{
// Arrange
string createRequestJson = LoadTraceFile("basic/create_conversation_request.json");
string firstMessageRequestJson = LoadTraceFile("basic/first_message_request.json");
string secondMessageRequestJson = LoadTraceFile("basic/second_message_request.json");
using var firstMessageExpectedDoc = LoadTraceDocument("basic/first_message_response.json");
using var secondMessageExpectedDoc = LoadTraceDocument("basic/second_message_response.json");
// Get expected response texts
string firstExpectedText = firstMessageExpectedDoc.RootElement.GetProperty("output")[0]
.GetProperty("content")[0]
.GetProperty("text").GetString()!;
string secondExpectedText = secondMessageExpectedDoc.RootElement.GetProperty("output")[0]
.GetProperty("content")[0]
.GetProperty("text").GetString()!;
// Create a stateful mock that returns different responses for each call
HttpClient client = await this.CreateTestServerWithStatefulMockAsync(
"basic-agent",
"You are a helpful assistant.",
[firstExpectedText, secondExpectedText]);
// Act - Create conversation
HttpResponseMessage createResponse = await SendPostRequestAsync(client, "/v1/conversations", createRequestJson);
using var createDoc = await ParseResponseAsync(createResponse);
var conversation = createDoc.RootElement;
string conversationId = conversation.GetProperty("id").GetString()!;
// Act - Send first message (using Responses API with conversation parameter)
// Update the request JSON with the actual conversation ID
using var firstMsgDoc = JsonDocument.Parse(firstMessageRequestJson);
var firstMsgRequest = JsonSerializer.Serialize(new
{
model = firstMsgDoc.RootElement.GetProperty("model").GetString(),
conversation = conversationId,
input = firstMsgDoc.RootElement.GetProperty("input").GetString(),
max_output_tokens = firstMsgDoc.RootElement.GetProperty("max_output_tokens").GetInt32()
});
HttpResponseMessage firstMsgResponse = await SendPostRequestAsync(client, "/basic-agent/v1/responses", firstMsgRequest);
using var firstMsgResponseDoc = await ParseResponseAsync(firstMsgResponse);
var firstResponse = firstMsgResponseDoc.RootElement;
// Assert - First response has conversation reference
AssertJsonPropertyExists(firstResponse, "conversation");
var conversationRef = firstResponse.GetProperty("conversation");
// The conversation reference can be either a string (just the ID) or an object with an id property
if (conversationRef.ValueKind == JsonValueKind.String)
{
var refId = conversationRef.GetString();
Assert.Equal(conversationId, refId);
}
else if (conversationRef.ValueKind == JsonValueKind.Object)
{
AssertJsonPropertyEquals(conversationRef, "id", conversationId);
}
else
{
Assert.Fail($"Expected conversation to be either a string or an object, but got {conversationRef.ValueKind}");
}
// Assert - First response has output
AssertJsonPropertyExists(firstResponse, "output");
var firstOutput = firstResponse.GetProperty("output");
Assert.True(firstOutput.GetArrayLength() > 0);
// Assert - First response status is completed
AssertJsonPropertyEquals(firstResponse, "status", "completed");
// Act - Send second message
using var secondMsgDoc = JsonDocument.Parse(secondMessageRequestJson);
var secondMsgRequest = JsonSerializer.Serialize(new
{
model = secondMsgDoc.RootElement.GetProperty("model").GetString(),
conversation = conversationId,
input = secondMsgDoc.RootElement.GetProperty("input").GetString(),
max_output_tokens = secondMsgDoc.RootElement.GetProperty("max_output_tokens").GetInt32()
});
HttpResponseMessage secondMsgResponse = await SendPostRequestAsync(client, "/basic-agent/v1/responses", secondMsgRequest);
using var secondMsgResponseDoc = await ParseResponseAsync(secondMsgResponse);
var secondResponse = secondMsgResponseDoc.RootElement;
// Assert - Second response has conversation reference
AssertJsonPropertyExists(secondResponse, "conversation");
var secondConversationRef = secondResponse.GetProperty("conversation");
if (secondConversationRef.ValueKind == JsonValueKind.String)
{
var refId = secondConversationRef.GetString();
Assert.Equal(conversationId, refId);
}
else if (secondConversationRef.ValueKind == JsonValueKind.Object)
{
AssertJsonPropertyEquals(secondConversationRef, "id", conversationId);
}
else
{
Assert.Fail($"Expected conversation to be either a string or an object, but got {secondConversationRef.ValueKind}");
}
// Assert - Second response has output
AssertJsonPropertyExists(secondResponse, "output");
var secondOutput = secondResponse.GetProperty("output");
Assert.True(secondOutput.GetArrayLength() > 0);
// Assert - Second response status is completed
AssertJsonPropertyEquals(secondResponse, "status", "completed");
}
[Fact]
public async Task CreateConversationWithItemsAsync()
{
// Arrange
string requestJson = LoadTraceFile("create_with_items/create_request.json");
using var expectedResponseDoc = LoadTraceDocument("create_with_items/create_response.json");
HttpClient client = await this.CreateTestServerAsync("items-agent", "You are a helpful assistant.", "Test response");
// Act
HttpResponseMessage httpResponse = await SendPostRequestAsync(client, "/v1/conversations", requestJson);
using var responseDoc = await ParseResponseAsync(httpResponse);
var response = responseDoc.RootElement;
// Parse the request
using var requestDoc = JsonDocument.Parse(requestJson);
var request = requestDoc.RootElement;
// Assert - Request has items array
AssertJsonPropertyExists(request, "items");
var requestItems = request.GetProperty("items");
Assert.Equal(JsonValueKind.Array, requestItems.ValueKind);
Assert.True(requestItems.GetArrayLength() > 0);
// Assert - Response has conversation structure
AssertJsonPropertyExists(response, "id");
AssertJsonPropertyEquals(response, "object", "conversation");
AssertJsonPropertyExists(response, "created_at");
AssertJsonPropertyExists(response, "metadata");
}
[Fact]
public async Task AddItemsToConversationAsync()
{
// Arrange
string createRequestJson = LoadTraceFile("basic/create_conversation_request.json");
string addItemsRequestJson = LoadTraceFile("add_items/request.json");
using var expectedResponseDoc = LoadTraceDocument("add_items/response.json");
HttpClient client = await this.CreateTestServerAsync("add-items-agent", "You are a helpful assistant.", "Test response");
// Act - Create conversation first
HttpResponseMessage createResponse = await SendPostRequestAsync(client, "/v1/conversations", createRequestJson);
using var createDoc = await ParseResponseAsync(createResponse);
var conversation = createDoc.RootElement;
string conversationId = conversation.GetProperty("id").GetString()!;
// Act - Add items
HttpResponseMessage addItemsResponse = await SendPostRequestAsync(client, $"/v1/conversations/{conversationId}/items", addItemsRequestJson);
using var addItemsDoc = await ParseResponseAsync(addItemsResponse);
var response = addItemsDoc.RootElement;
// Parse the request
using var requestDoc = JsonDocument.Parse(addItemsRequestJson);
var request = requestDoc.RootElement;
// Assert - Request has items array
AssertJsonPropertyExists(request, "items");
var requestItems = request.GetProperty("items");
Assert.Equal(JsonValueKind.Array, requestItems.ValueKind);
var itemCount = requestItems.GetArrayLength();
Assert.True(itemCount > 0);
// Assert - Response has data array with created items
AssertJsonPropertyExists(response, "data");
var responseData = response.GetProperty("data");
Assert.Equal(JsonValueKind.Array, responseData.ValueKind);
Assert.Equal(itemCount, responseData.GetArrayLength());
// Assert - Each item has required fields
foreach (var item in responseData.EnumerateArray())
{
AssertJsonPropertyExists(item, "id");
AssertJsonPropertyEquals(item, "type", "message");
AssertJsonPropertyExists(item, "content");
AssertJsonPropertyExists(item, "role");
var itemId = item.GetProperty("id").GetString();
Assert.NotNull(itemId);
Assert.StartsWith("msg_", itemId);
}
}
[Fact]
public async Task ListItemsInConversationAsync()
{
// Arrange
string createRequestJson = LoadTraceFile("basic/create_conversation_request.json");
using var expectedResponseDoc = LoadTraceDocument("list_items/response.json");
HttpClient client = await this.CreateTestServerAsync("list-items-agent", "You are a helpful assistant.", "The capital of France is Paris.");
// Act - Create conversation
HttpResponseMessage createResponse = await SendPostRequestAsync(client, "/v1/conversations", createRequestJson);
using var createDoc = await ParseResponseAsync(createResponse);
var conversation = createDoc.RootElement;
string conversationId = conversation.GetProperty("id").GetString()!;
// Act - List items
HttpResponseMessage listResponse = await SendGetRequestAsync(client, $"/v1/conversations/{conversationId}/items");
using var listDoc = await ParseResponseAsync(listResponse);
var response = listDoc.RootElement;
// Assert - Response has list structure
AssertJsonPropertyEquals(response, "object", "list");
AssertJsonPropertyExists(response, "data");
AssertJsonPropertyExists(response, "first_id");
AssertJsonPropertyExists(response, "last_id");
AssertJsonPropertyExists(response, "has_more");
var data = response.GetProperty("data");
Assert.Equal(JsonValueKind.Array, data.ValueKind);
}
[Fact]
public async Task RetrieveConversationAsync()
{
// Arrange
string createRequestJson = LoadTraceFile("basic/create_conversation_request.json");
using var expectedResponseDoc = LoadTraceDocument("retrieve_conversation/response.json");
HttpClient client = await this.CreateTestServerAsync("retrieve-agent", "You are a helpful assistant.", "Test response");
// Act - Create conversation
HttpResponseMessage createResponse = await SendPostRequestAsync(client, "/v1/conversations", createRequestJson);
using var createDoc = await ParseResponseAsync(createResponse);
var createdConversation = createDoc.RootElement;
string conversationId = createdConversation.GetProperty("id").GetString()!;
// Act - Retrieve conversation
HttpResponseMessage retrieveResponse = await SendGetRequestAsync(client, $"/v1/conversations/{conversationId}");
using var retrieveDoc = await ParseResponseAsync(retrieveResponse);
var response = retrieveDoc.RootElement;
// Assert - Response has conversation structure
AssertJsonPropertyExists(response, "id");
AssertJsonPropertyEquals(response, "object", "conversation");
AssertJsonPropertyExists(response, "created_at");
AssertJsonPropertyExists(response, "metadata");
var id = response.GetProperty("id").GetString();
Assert.Equal(conversationId, id);
}
[Fact]
public async Task RetrieveItemAsync()
{
// Arrange
string createRequestJson = LoadTraceFile("create_with_items/create_request.json");
using var expectedResponseDoc = LoadTraceDocument("retrieve_item/response.json");
HttpClient client = await this.CreateTestServerAsync("retrieve-item-agent", "You are a helpful assistant.", "Test response");
// Act - Create conversation with items
HttpResponseMessage createResponse = await SendPostRequestAsync(client, "/v1/conversations", createRequestJson);
using var createDoc = await ParseResponseAsync(createResponse);
var conversation = createDoc.RootElement;
string conversationId = conversation.GetProperty("id").GetString()!;
// Act - List items to get an item ID
HttpResponseMessage listResponse = await SendGetRequestAsync(client, $"/v1/conversations/{conversationId}/items");
using var listDoc = await ParseResponseAsync(listResponse);
var listResult = listDoc.RootElement;
var items = listResult.GetProperty("data");
Assert.True(items.GetArrayLength() > 0, "Should have at least one item");
string itemId = items[0].GetProperty("id").GetString()!;
// Act - Retrieve specific item
HttpResponseMessage retrieveResponse = await SendGetRequestAsync(client, $"/v1/conversations/{conversationId}/items/{itemId}");
using var retrieveDoc = await ParseResponseAsync(retrieveResponse);
var response = retrieveDoc.RootElement;
// Assert - Response has item structure
AssertJsonPropertyExists(response, "id");
AssertJsonPropertyEquals(response, "type", "message");
AssertJsonPropertyExists(response, "content");
AssertJsonPropertyExists(response, "role");
var id = response.GetProperty("id").GetString();
Assert.Equal(itemId, id);
}
[Fact]
public async Task UpdateConversationAsync()
{
// Arrange
string createRequestJson = LoadTraceFile("basic/create_conversation_request.json");
string updateRequestJson = LoadTraceFile("update_conversation/request.json");
using var expectedResponseDoc = LoadTraceDocument("update_conversation/response.json");
HttpClient client = await this.CreateTestServerAsync("update-agent", "You are a helpful assistant.", "Test response");
// Act - Create conversation
HttpResponseMessage createResponse = await SendPostRequestAsync(client, "/v1/conversations", createRequestJson);
using var createDoc = await ParseResponseAsync(createResponse);
var conversation = createDoc.RootElement;
string conversationId = conversation.GetProperty("id").GetString()!;
// Act - Update conversation
HttpResponseMessage updateResponse = await SendPostRequestAsync(client, $"/v1/conversations/{conversationId}", updateRequestJson);
using var updateDoc = await ParseResponseAsync(updateResponse);
var response = updateDoc.RootElement;
// Parse the request
using var requestDoc = JsonDocument.Parse(updateRequestJson);
var request = requestDoc.RootElement;
// Assert - Request has metadata
AssertJsonPropertyExists(request, "metadata");
var requestMetadata = request.GetProperty("metadata");
// Assert - Response preserves updated metadata
AssertJsonPropertyExists(response, "id");
AssertJsonPropertyEquals(response, "object", "conversation");
AssertJsonPropertyExists(response, "metadata");
var responseMetadata = response.GetProperty("metadata");
// Verify metadata was updated
foreach (var prop in requestMetadata.EnumerateObject())
{
Assert.True(responseMetadata.TryGetProperty(prop.Name, out var value));
Assert.Equal(prop.Value.GetString(), value.GetString());
}
}
[Fact]
public async Task DeleteConversationAsync()
{
// Arrange
string createRequestJson = LoadTraceFile("basic/create_conversation_request.json");
using var expectedResponseDoc = LoadTraceDocument("delete_conversation/response.json");
HttpClient client = await this.CreateTestServerAsync("delete-agent", "You are a helpful assistant.", "Test response");
// Act - Create conversation
HttpResponseMessage createResponse = await SendPostRequestAsync(client, "/v1/conversations", createRequestJson);
using var createDoc = await ParseResponseAsync(createResponse);
var conversation = createDoc.RootElement;
string conversationId = conversation.GetProperty("id").GetString()!;
// Act - Delete conversation
HttpResponseMessage deleteResponse = await SendDeleteRequestAsync(client, $"/v1/conversations/{conversationId}");
using var deleteDoc = await ParseResponseAsync(deleteResponse);
var response = deleteDoc.RootElement;
// Assert - Delete response structure
AssertJsonPropertyExists(response, "id");
AssertJsonPropertyEquals(response, "object", "conversation.deleted");
AssertJsonPropertyEquals(response, "deleted", true);
var id = response.GetProperty("id").GetString();
Assert.Equal(conversationId, id);
// Assert - Conversation is actually deleted
HttpResponseMessage retrieveResponse = await SendGetRequestAsync(client, $"/v1/conversations/{conversationId}");
Assert.Equal(System.Net.HttpStatusCode.NotFound, retrieveResponse.StatusCode);
}
[Fact]
public async Task DeleteItemAsync()
{
// Arrange
string createRequestJson = LoadTraceFile("create_with_items/create_request.json");
using var expectedResponseDoc = LoadTraceDocument("delete_item/response.json");
HttpClient client = await this.CreateTestServerAsync("delete-item-agent", "You are a helpful assistant.", "Test response");
// Act - Create conversation with items
HttpResponseMessage createResponse = await SendPostRequestAsync(client, "/v1/conversations", createRequestJson);
using var createDoc = await ParseResponseAsync(createResponse);
var conversation = createDoc.RootElement;
string conversationId = conversation.GetProperty("id").GetString()!;
// Act - List items to get an item ID
HttpResponseMessage listResponse = await SendGetRequestAsync(client, $"/v1/conversations/{conversationId}/items");
using var listDoc = await ParseResponseAsync(listResponse);
var listResult = listDoc.RootElement;
var items = listResult.GetProperty("data");
Assert.True(items.GetArrayLength() > 0, "Should have at least one item");
string itemId = items[0].GetProperty("id").GetString()!;
// Act - Delete item
HttpResponseMessage deleteResponse = await SendDeleteRequestAsync(client, $"/v1/conversations/{conversationId}/items/{itemId}");
using var deleteDoc = await ParseResponseAsync(deleteResponse);
var response = deleteDoc.RootElement;
// Assert - Delete response structure
AssertJsonPropertyExists(response, "id");
AssertJsonPropertyEquals(response, "object", "conversation.item.deleted");
AssertJsonPropertyEquals(response, "deleted", true);
var id = response.GetProperty("id").GetString();
Assert.Equal(itemId, id);
// Assert - Item is actually deleted
HttpResponseMessage retrieveResponse = await SendGetRequestAsync(client, $"/v1/conversations/{conversationId}/items/{itemId}");
Assert.Equal(System.Net.HttpStatusCode.NotFound, retrieveResponse.StatusCode);
}
[Fact]
public async Task ErrorConversationNotFoundAsync()
{
// Arrange
using var expectedResponseDoc = LoadTraceDocument("error_conversation_not_found/response.json");
const string NonExistentConversationId = "conv_nonexistent123456789";
HttpClient client = await this.CreateTestServerAsync("error-agent", "You are a helpful assistant.", "Test response");
// Act
HttpResponseMessage response = await SendGetRequestAsync(client, $"/v1/conversations/{NonExistentConversationId}");
using var responseDoc = await ParseResponseAsync(response);
var responseJson = responseDoc.RootElement;
// Assert - Response is 404
Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode);
// Assert - Error response structure
AssertJsonPropertyExists(responseJson, "error");
var error = responseJson.GetProperty("error");
AssertJsonPropertyExists(error, "message");
AssertJsonPropertyExists(error, "type");
var errorMessage = error.GetProperty("message").GetString();
Assert.NotNull(errorMessage);
Assert.Contains("not found", errorMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ErrorItemNotFoundAsync()
{
// Arrange
string createRequestJson = LoadTraceFile("basic/create_conversation_request.json");
using var expectedResponseDoc = LoadTraceDocument("error_item_not_found/response.json");
const string NonExistentItemId = "msg_nonexistent123456789";
HttpClient client = await this.CreateTestServerAsync("error-item-agent", "You are a helpful assistant.", "Test response");
// Act - Create conversation
HttpResponseMessage createResponse = await SendPostRequestAsync(client, "/v1/conversations", createRequestJson);
using var createDoc = await ParseResponseAsync(createResponse);
var conversation = createDoc.RootElement;
string conversationId = conversation.GetProperty("id").GetString()!;
// Act - Try to retrieve non-existent item
HttpResponseMessage response = await SendGetRequestAsync(client, $"/v1/conversations/{conversationId}/items/{NonExistentItemId}");
using var responseDoc = await ParseResponseAsync(response);
var responseJson = responseDoc.RootElement;
// Assert - Response is 404
Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode);
// Assert - Error response structure
AssertJsonPropertyExists(responseJson, "error");
var error = responseJson.GetProperty("error");
AssertJsonPropertyExists(error, "message");
AssertJsonPropertyExists(error, "type");
var errorMessage = error.GetProperty("message").GetString();
Assert.NotNull(errorMessage);
Assert.Contains("not found", errorMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ErrorInvalidJsonAsync()
{
// Arrange
string invalidJson = LoadTraceFile("error_invalid_json/request.txt");
using var expectedResponseDoc = LoadTraceDocument("error_invalid_json/response.json");
HttpClient client = await this.CreateTestServerAsync("error-json-agent", "You are a helpful assistant.", "Test response");
// Act
using StringContent content = new(invalidJson, Encoding.UTF8, "application/json");
HttpResponseMessage response = await client.PostAsync(new Uri("/v1/conversations", UriKind.Relative), content);
// Assert - Response is 400
Assert.Equal(System.Net.HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task ErrorDeleteAlreadyDeletedAsync()
{
// Arrange
using var expectedResponseDoc = LoadTraceDocument("error_delete_already_deleted/response.json");
HttpClient client = await this.CreateTestServerAsync("delete-twice-agent", "You are a helpful assistant.", "Test response");
// Create a conversation
string createRequestJson = LoadTraceFile("basic/create_conversation_request.json");
HttpResponseMessage createResponse = await SendPostRequestAsync(client, "/v1/conversations", createRequestJson);
using var createDoc = await ParseResponseAsync(createResponse);
string conversationId = createDoc.RootElement.GetProperty("id").GetString()!;
// Delete the conversation
await SendDeleteRequestAsync(client, $"/v1/conversations/{conversationId}");
// Act - Try to delete again
HttpResponseMessage response = await SendDeleteRequestAsync(client, $"/v1/conversations/{conversationId}");
using var responseDoc = await ParseResponseAsync(response);
var responseJson = responseDoc.RootElement;
// Assert - Should return 404
Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode);
// Assert - Error response structure
AssertJsonPropertyExists(responseJson, "error");
var error = responseJson.GetProperty("error");
AssertJsonPropertyExists(error, "message");
AssertJsonPropertyExists(error, "type");
var errorMessage = error.GetProperty("message").GetString();
Assert.NotNull(errorMessage);
Assert.Contains("not found", errorMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ErrorInvalidLimitAsync()
{
// Arrange
using var expectedResponseDoc = LoadTraceDocument("error_invalid_limit/response.json");
HttpClient client = await this.CreateTestServerAsync("invalid-limit-agent", "You are a helpful assistant.", "Test response");
// Create a conversation
string createRequestJson = LoadTraceFile("basic/create_conversation_request.json");
HttpResponseMessage createResponse = await SendPostRequestAsync(client, "/v1/conversations", createRequestJson);
using var createDoc = await ParseResponseAsync(createResponse);
string conversationId = createDoc.RootElement.GetProperty("id").GetString()!;
// Act - Request items with invalid limit (e.g., negative or too large)
HttpResponseMessage response = await SendGetRequestAsync(client, $"/v1/conversations/{conversationId}/items?limit=-1");
using var responseDoc = await ParseResponseAsync(response);
var responseJson = responseDoc.RootElement;
// Assert - Should return 400
Assert.Equal(System.Net.HttpStatusCode.BadRequest, response.StatusCode);
// Assert - Error response structure
AssertJsonPropertyExists(responseJson, "error");
var error = responseJson.GetProperty("error");
AssertJsonPropertyExists(error, "message");
AssertJsonPropertyExists(error, "type");
var errorMessage = error.GetProperty("message").GetString();
Assert.NotNull(errorMessage);
}
[Fact]
public async Task ToolCallFullScenarioAsync()
{
// Arrange - Full test for tool call scenario through Conversations and Responses API
string createRequestJson = LoadTraceFile("tool_call/create_conversation_request.json");
string firstMessageRequestJson = LoadTraceFile("tool_call/first_message_request.json");
using var messageExpectedDoc = LoadTraceDocument("tool_call/first_message_response.json");
// Extract function call details from expected response
var expectedOutput = messageExpectedDoc.RootElement.GetProperty("output")[0];
string functionName = expectedOutput.GetProperty("name").GetString()!;
string arguments = expectedOutput.GetProperty("arguments").GetString()!;
// Create server with proper tool call mock
HttpClient client = await this.CreateTestServerWithToolCallAsync("tool-call-agent", "You are a helpful assistant.", functionName, arguments);
// Act - Create conversation
HttpResponseMessage createResponse = await SendPostRequestAsync(client, "/v1/conversations", createRequestJson);
using var createDoc = await ParseResponseAsync(createResponse);
var conversation = createDoc.RootElement;
string conversationId = conversation.GetProperty("id").GetString()!;
// Act - Send message with tools through Responses API
using var msgDoc = JsonDocument.Parse(firstMessageRequestJson);
var msgRequest = JsonSerializer.Serialize(new
{
model = msgDoc.RootElement.GetProperty("model").GetString(),
conversation = conversationId,
input = msgDoc.RootElement.GetProperty("input"),
tools = msgDoc.RootElement.GetProperty("tools"),
max_output_tokens = msgDoc.RootElement.GetProperty("max_output_tokens").GetInt32()
});
HttpResponseMessage msgResponse = await SendPostRequestAsync(client, "/tool-call-agent/v1/responses", msgRequest);
using var msgResponseDoc = await ParseResponseAsync(msgResponse);
var response = msgResponseDoc.RootElement;
// Assert - Response has conversation reference
AssertJsonPropertyExists(response, "conversation");
AssertJsonPropertyEquals(response, "status", "completed");
// Assert - Response has function call output
AssertJsonPropertyExists(response, "output");
var output = response.GetProperty("output");
Assert.True(output.GetArrayLength() > 0);
// Assert - Output contains function call
var outputItem = output[0];
AssertJsonPropertyEquals(outputItem, "type", "function_call");
AssertJsonPropertyEquals(outputItem, "name", functionName);
AssertJsonPropertyExists(outputItem, "arguments");
}
[Fact]
public async Task ImageInputFullScenarioAsync()
{
// Arrange - Full test for image input scenario through Conversations and Responses API
string createRequestJson = LoadTraceFile("image_input/create_conversation_request.json");
string firstMessageRequestJson = LoadTraceFile("image_input/first_message_request.json");
using var createExpectedDoc = LoadTraceDocument("image_input/create_conversation_response.json");
using var messageExpectedDoc = LoadTraceDocument("image_input/first_message_response.json");
// Get expected response text
string expectedText = messageExpectedDoc.RootElement.GetProperty("output")[0]
.GetProperty("content")[0]
.GetProperty("text").GetString()!;
HttpClient client = await this.CreateTestServerAsync("image-input-agent", "You are a helpful assistant.", expectedText);
// Act - Create conversation
HttpResponseMessage createResponse = await SendPostRequestAsync(client, "/v1/conversations", createRequestJson);
using var createDoc = await ParseResponseAsync(createResponse);
var conversation = createDoc.RootElement;
string conversationId = conversation.GetProperty("id").GetString()!;
// Parse the image input request to verify structure
using var requestDoc = JsonDocument.Parse(firstMessageRequestJson);
var request = requestDoc.RootElement;
// Assert - Request structure with image content (validates we're testing the right scenario)
AssertJsonPropertyExists(request, "input");
var input = request.GetProperty("input");
Assert.Equal(JsonValueKind.Array, input.ValueKind);
var message = input[0];
AssertJsonPropertyExists(message, "content");
var content = message.GetProperty("content");
Assert.True(content.GetArrayLength() > 1, "Should have text and image content");
// Assert - Has input_image content type
JsonElement? imagePart = content.EnumerateArray()
.Where(part => part.GetProperty("type").GetString() == "input_image")
.Cast<JsonElement?>()
.FirstOrDefault();
bool hasImage = imagePart.HasValue;
if (hasImage)
{
AssertJsonPropertyExists(imagePart!.Value, "image_url");
}
Assert.True(hasImage, "Request should have input_image content");
// Act - Send message with image through Responses API
using var msgDoc = JsonDocument.Parse(firstMessageRequestJson);
var msgRequest = JsonSerializer.Serialize(new
{
model = msgDoc.RootElement.GetProperty("model").GetString(),
conversation = conversationId,
input = msgDoc.RootElement.GetProperty("input"),
max_output_tokens = msgDoc.RootElement.GetProperty("max_output_tokens").GetInt32()
});
HttpResponseMessage msgResponse = await SendPostRequestAsync(client, "/image-input-agent/v1/responses", msgRequest);
using var msgResponseDoc = await ParseResponseAsync(msgResponse);
var response = msgResponseDoc.RootElement;
// Assert - Response has conversation reference (validates integration)
AssertJsonPropertyExists(response, "conversation");
AssertJsonPropertyEquals(response, "status", "completed");
// Assert - Response has output (validates the system processed the request successfully)
AssertJsonPropertyExists(response, "output");
var output = response.GetProperty("output");
Assert.True(output.GetArrayLength() > 0);
}
[Fact]
public async Task ImageInputStreamingScenarioAsync()
{
// Arrange - Test streaming response with image input through Conversations + Responses API
string createRequestJson = LoadTraceFile("image_input_streaming/create_conversation_request.json");
string firstMessageRequestJson = LoadTraceFile("image_input_streaming/first_message_request.json");
string expectedResponseSse = LoadTraceFile("image_input_streaming/first_message_response.txt");
// Extract expected text from SSE events
var expectedEvents = ParseSseEventsFromContent(expectedResponseSse);
var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList();
string expectedText = string.Concat(deltaEvents.Select(e => e.GetProperty("delta").GetString()));
HttpClient client = await this.CreateTestServerAsync("image-streaming-agent", "You are a helpful assistant.", expectedText);
// Act - Create conversation
HttpResponseMessage createResponse = await SendPostRequestAsync(client, "/v1/conversations", createRequestJson);
using var createDoc = await ParseResponseAsync(createResponse);
var conversation = createDoc.RootElement;
string conversationId = conversation.GetProperty("id").GetString()!;
// Prepare streaming request with conversation
using var msgDoc = JsonDocument.Parse(firstMessageRequestJson);
var msgRequest = JsonSerializer.Serialize(new
{
model = msgDoc.RootElement.GetProperty("model").GetString(),
conversation = conversationId,
input = msgDoc.RootElement.GetProperty("input"),
stream = true,
max_output_tokens = msgDoc.RootElement.GetProperty("max_output_tokens").GetInt32()
});
// Act - Send streaming request
HttpResponseMessage streamResponse = await SendPostRequestAsync(client, "/image-streaming-agent/v1/responses", msgRequest);
// Assert - Response should be SSE format (validates streaming works with image input)
Assert.Equal("text/event-stream", streamResponse.Content.Headers.ContentType?.MediaType);
string responseSse = await streamResponse.Content.ReadAsStringAsync();
var events = ParseSseEventsFromContent(responseSse);
// Assert - Has expected event types (validates proper streaming event structure)
var eventTypes = events.ConvertAll(e => e.GetProperty("type").GetString()!);
Assert.Contains("response.created", eventTypes);
Assert.Contains("response.output_text.delta", eventTypes);
}
[Fact]
public async Task RefusalStreamingScenarioAsync()
{
// Arrange - Test streaming response with refusal through Conversations + Responses API
string createRequestJson = LoadTraceFile("basic/create_conversation_request.json");
string firstMessageRequestJson = LoadTraceFile("refusal_streaming/first_message_request.json");
string expectedResponseSse = LoadTraceFile("refusal_streaming/first_message_response.txt");
// Extract expected text from SSE events
var expectedEvents = ParseSseEventsFromContent(expectedResponseSse);
var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList();
string expectedText = string.Concat(deltaEvents.Select(e => e.GetProperty("delta").GetString()));
HttpClient client = await this.CreateTestServerAsync("refusal-streaming-agent", "You are a helpful assistant.", expectedText);
// Act - Create conversation
HttpResponseMessage createResponse = await SendPostRequestAsync(client, "/v1/conversations", createRequestJson);
using var createDoc = await ParseResponseAsync(createResponse);
var conversation = createDoc.RootElement;
string conversationId = conversation.GetProperty("id").GetString()!;
// Prepare streaming request with conversation
using var msgDoc = JsonDocument.Parse(firstMessageRequestJson);
var msgRequest = JsonSerializer.Serialize(new
{
model = msgDoc.RootElement.GetProperty("model").GetString(),
conversation = conversationId,
input = msgDoc.RootElement.GetProperty("input"),
stream = true,
max_output_tokens = msgDoc.RootElement.GetProperty("max_output_tokens").GetInt32()
});
// Act - Send streaming request
HttpResponseMessage streamResponse = await SendPostRequestAsync(client, "/refusal-streaming-agent/v1/responses", msgRequest);
// Assert - Response should be SSE format
Assert.Equal("text/event-stream", streamResponse.Content.Headers.ContentType?.MediaType);
string responseSse = await streamResponse.Content.ReadAsStringAsync();
var events = ParseSseEventsFromContent(responseSse);
// Assert - Has expected event types (conformance check)
var eventTypes = events.ConvertAll(e => e.GetProperty("type").GetString()!);
Assert.Contains("response.created", eventTypes);
Assert.Contains("response.output_text.delta", eventTypes);
// Assert - Text contains refusal (validates refusal content is in streaming output)
var doneEvent = events.First(e => e.GetProperty("type").GetString() == "response.output_text.done");
var finalText = doneEvent.GetProperty("text").GetString();
Assert.NotNull(finalText);
Assert.Contains("can't assist", finalText, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ToolCallStreamingScenarioAsync()
{
// Arrange - Test streaming response with tool call through Conversations + Responses API
string createRequestJson = LoadTraceFile("basic/create_conversation_request.json");
string firstMessageRequestJson = LoadTraceFile("tool_call_streaming/first_message_request.json");
// Use tool call details from the non-streaming test
using var messageExpectedDoc = LoadTraceDocument("tool_call/first_message_response.json");
var expectedOutput = messageExpectedDoc.RootElement.GetProperty("output")[0];
string functionName = expectedOutput.GetProperty("name").GetString()!;
string arguments = expectedOutput.GetProperty("arguments").GetString()!;
HttpClient client = await this.CreateTestServerWithToolCallAsync("tool-streaming-agent", "You are a helpful assistant.", functionName, arguments);
// Act - Create conversation
HttpResponseMessage createResponse = await SendPostRequestAsync(client, "/v1/conversations", createRequestJson);
using var createDoc = await ParseResponseAsync(createResponse);
var conversation = createDoc.RootElement;
string conversationId = conversation.GetProperty("id").GetString()!;
// Prepare streaming request with conversation
using var msgDoc = JsonDocument.Parse(firstMessageRequestJson);
var msgRequest = JsonSerializer.Serialize(new
{
model = msgDoc.RootElement.GetProperty("model").GetString(),
conversation = conversationId,
input = msgDoc.RootElement.GetProperty("input"),
tools = msgDoc.RootElement.GetProperty("tools"),
stream = true,
max_output_tokens = msgDoc.RootElement.GetProperty("max_output_tokens").GetInt32()
});
// Act - Send streaming request
HttpResponseMessage streamResponse = await SendPostRequestAsync(client, "/tool-streaming-agent/v1/responses", msgRequest);
// Assert - Response should be SSE format
Assert.Equal("text/event-stream", streamResponse.Content.Headers.ContentType?.MediaType);
string responseSse = await streamResponse.Content.ReadAsStringAsync();
var events = ParseSseEventsFromContent(responseSse);
// Assert - Has expected event types for function call streaming
var eventTypes = events.ConvertAll(e => e.GetProperty("type").GetString()!);
Assert.Contains("response.created", eventTypes);
}
[Fact]
public async Task RefusalFullScenarioAsync()
{
// Arrange - Full test for refusal scenario through Conversations and Responses API
string createRequestJson = LoadTraceFile("basic/create_conversation_request.json");
string firstMessageRequestJson = LoadTraceFile("refusal/first_message_request.json");
using var createExpectedDoc = LoadTraceDocument("refusal/create_conversation_response.json");
using var messageExpectedDoc = LoadTraceDocument("refusal/first_message_response.json");
// Get expected response text (refusal message)
string expectedText = messageExpectedDoc.RootElement.GetProperty("output")[0]
.GetProperty("content")[0]
.GetProperty("text").GetString()!;
HttpClient client = await this.CreateTestServerAsync("refusal-agent", "You are a helpful assistant.", expectedText);
// Act - Create conversation
HttpResponseMessage createResponse = await SendPostRequestAsync(client, "/v1/conversations", createRequestJson);
using var createDoc = await ParseResponseAsync(createResponse);
var conversation = createDoc.RootElement;
string conversationId = conversation.GetProperty("id").GetString()!;
// Parse the refusal request to verify structure
using var requestDoc = JsonDocument.Parse(firstMessageRequestJson);
var request = requestDoc.RootElement;
// Assert - Request structure (input can be string or array depending on the request format)
AssertJsonPropertyExists(request, "input");
var input = request.GetProperty("input");
Assert.True(input.ValueKind is JsonValueKind.String or JsonValueKind.Array);
// Act - Send message through Responses API
using var msgDoc = JsonDocument.Parse(firstMessageRequestJson);
var msgRequest = JsonSerializer.Serialize(new
{
model = msgDoc.RootElement.GetProperty("model").GetString(),
conversation = conversationId,
input = msgDoc.RootElement.GetProperty("input"),
max_output_tokens = msgDoc.RootElement.GetProperty("max_output_tokens").GetInt32()
});
HttpResponseMessage msgResponse = await SendPostRequestAsync(client, "/refusal-agent/v1/responses", msgRequest);
using var msgResponseDoc = await ParseResponseAsync(msgResponse);
var response = msgResponseDoc.RootElement;
// Assert - Response has conversation reference (validates integration)
AssertJsonPropertyExists(response, "conversation");
// Assert - Refusals should be completed, not failed (important behavioral validation)
AssertJsonPropertyEquals(response, "status", "completed");
// Assert - Response has output with refusal (validates structure)
AssertJsonPropertyExists(response, "output");
var output = response.GetProperty("output");
Assert.True(output.GetArrayLength() > 0);
var outputMessage = output[0];
var outputContent = outputMessage.GetProperty("content");
var textContent = outputContent[0];
var text = textContent.GetProperty("text").GetString();
Assert.NotNull(text);
// Validate refusal pattern (confirms we're testing the right scenario)
Assert.Contains("can't assist", text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ErrorMissingRequiredFieldAsync()
{
// Arrange
string requestJson = LoadTraceFile("error_missing_required_field/request.json");
HttpClient client = await this.CreateTestServerAsync("missing-field-agent", "You are a helpful assistant.", "Test response");
// Create a conversation first
string createRequestJson = LoadTraceFile("basic/create_conversation_request.json");
HttpResponseMessage createResponse = await SendPostRequestAsync(client, "/v1/conversations", createRequestJson);
using var createDoc = await ParseResponseAsync(createResponse);
string conversationId = createDoc.RootElement.GetProperty("id").GetString()!;
// Act - Send request with missing required field (role is missing)
HttpResponseMessage response = await SendPostRequestAsync(client, $"/v1/conversations/{conversationId}/items", requestJson);
// Assert - System should reject the request with a client error status code
// We accept 400 (Bad Request) or 422 (Unprocessable Entity) as both indicate validation failure
Assert.True(
response.StatusCode is System.Net.HttpStatusCode.BadRequest or
System.Net.HttpStatusCode.UnprocessableEntity,
$"Expected 400 or 422 status code for missing required field, but got {(int)response.StatusCode} ({response.StatusCode})");
}
public async ValueTask DisposeAsync()
{
this._httpClient?.Dispose();
if (this._app != null)
{
await this._app.DisposeAsync();
}
GC.SuppressFinalize(this);
}
/// <summary>
/// Helper to parse SSE events from streaming response content string.
/// </summary>
private static List<JsonElement> ParseSseEventsFromContent(string sseContent)
{
var events = new 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);
var doc = JsonDocument.Parse(jsonData);
events.Add(doc.RootElement.Clone());
}
}
}
return events;
}
}