mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
3b6a4574eb
* Fix OpenAIResponsesAgentClient endpoint to include agentName in path (#5324) The sample OpenAIResponsesAgentClient used '/v1/' as the endpoint, which routes to the multi-agent endpoint requiring agent.name in the request body. However, AsIChatClient(agentName) maps agentName to the model field, not agent.name, causing HTTP 400 errors on OpenAI-compatible endpoints. Changed the endpoint to '/{agentName}/v1/' to match the pattern used by OpenAIChatCompletionsAgentClient, routing to the single-agent endpoint where no agent.name body field is needed. Added regression test verifying that the model field alone is insufficient for agent resolution on the multi-agent endpoint. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #5324 - URL-escape agentName in OpenAIResponsesAgentClient endpoint path to handle reserved characters safely - Add per-agent MapOpenAIResponses() calls in AgentHost so the sample host serves the /{agentName}/v1/responses routes the client now targets - Replace brittle Assert.Contains("agent.name") assertions with stable machine-readable error code assertion ("missing_required_parameter") Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address additional review feedback for #5324 - Apply Uri.EscapeDataString to OpenAIChatCompletionsAgentClient endpoint for consistency with OpenAIResponsesAgentClient - Map OpenAI Responses and ChatCompletions endpoints for all builder-based agents (chemist, mathematician, literator, science workflows) so every discoverable agent is reachable via the single-agent endpoint path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
475 lines
19 KiB
C#
475 lines
19 KiB
C#
// Copyright (c) Microsoft. All rights reserved.
|
|
|
|
using System;
|
|
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 MapOpenAIResponses variant that resolves agents from the Agent.Name property.
|
|
/// These tests validate the agent resolution mechanism using the HostedAgentResponseExecutor.
|
|
/// </summary>
|
|
public sealed class OpenAIResponsesAgentResolutionIntegrationTests : IAsyncDisposable
|
|
{
|
|
private WebApplication? _app;
|
|
private HttpClient? _httpClient;
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
this._httpClient?.Dispose();
|
|
if (this._app != null)
|
|
{
|
|
await this._app.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that agent resolution works using the agent.name property in streaming mode.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task CreateResponseStreaming_WithAgentNameProperty_ResolvesCorrectAgentAsync()
|
|
{
|
|
// Arrange
|
|
const string AgentName = "test-agent";
|
|
const string Instructions = "You are a helpful assistant.";
|
|
const string ExpectedResponse = "Hello from agent resolution!";
|
|
|
|
this._httpClient = await this.CreateTestServerWithAgentResolutionAsync(
|
|
(AgentName, Instructions, ExpectedResponse));
|
|
|
|
// Act - Use raw HTTP request with agent.name specified
|
|
using StringContent requestContent = new(JsonSerializer.Serialize(new
|
|
{
|
|
agent = new { name = AgentName },
|
|
stream = true,
|
|
input = new[]
|
|
{
|
|
new { type = "message", role = "user", content = "Test message" }
|
|
}
|
|
}), Encoding.UTF8, "application/json");
|
|
|
|
using HttpResponseMessage httpResponse = await this._httpClient!.PostAsync(new Uri("/v1/responses", UriKind.Relative), requestContent);
|
|
|
|
// Assert
|
|
Assert.True(httpResponse.IsSuccessStatusCode, $"Request failed with status {httpResponse.StatusCode}");
|
|
|
|
string responseText = await httpResponse.Content.ReadAsStringAsync();
|
|
Assert.Contains(ExpectedResponse, responseText);
|
|
Assert.Contains("response.created", responseText);
|
|
Assert.Contains("response.completed", responseText);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that agent resolution works using the agent.name property in non-streaming mode.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task CreateResponse_WithAgentNameProperty_ResolvesCorrectAgentAsync()
|
|
{
|
|
// Arrange
|
|
const string AgentName = "test-agent";
|
|
const string Instructions = "You are a helpful assistant.";
|
|
const string ExpectedResponse = "Hello from agent resolution!";
|
|
|
|
this._httpClient = await this.CreateTestServerWithAgentResolutionAsync(
|
|
(AgentName, Instructions, ExpectedResponse));
|
|
|
|
// Act - Use raw HTTP request with agent.name specified
|
|
using StringContent requestContent = new(JsonSerializer.Serialize(new
|
|
{
|
|
agent = new { name = AgentName },
|
|
input = new[]
|
|
{
|
|
new { type = "message", role = "user", content = "Test message" }
|
|
}
|
|
}), Encoding.UTF8, "application/json");
|
|
|
|
using HttpResponseMessage httpResponse = await this._httpClient!.PostAsync(new Uri("/v1/responses", UriKind.Relative), requestContent);
|
|
|
|
// Assert
|
|
Assert.True(httpResponse.IsSuccessStatusCode, $"Request failed with status {httpResponse.StatusCode}");
|
|
|
|
string responseJson = await httpResponse.Content.ReadAsStringAsync();
|
|
using JsonDocument doc = JsonDocument.Parse(responseJson);
|
|
JsonElement root = doc.RootElement;
|
|
|
|
Assert.Equal("completed", root.GetProperty("status").GetString());
|
|
JsonElement outputArray = root.GetProperty("output");
|
|
Assert.True(outputArray.GetArrayLength() > 0);
|
|
|
|
JsonElement firstOutput = outputArray[0];
|
|
JsonElement contentArray = firstOutput.GetProperty("content");
|
|
JsonElement firstContent = contentArray[0];
|
|
string actualResponse = firstContent.GetProperty("text").GetString() ?? string.Empty;
|
|
|
|
Assert.Equal(ExpectedResponse, actualResponse);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that agent resolution can distinguish between multiple agents.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task CreateResponse_WithMultipleAgents_ResolvesCorrectAgentAsync()
|
|
{
|
|
// Arrange
|
|
const string Agent1Name = "agent-1";
|
|
const string Agent1Response = "Response from agent 1";
|
|
const string Agent2Name = "agent-2";
|
|
const string Agent2Response = "Response from agent 2";
|
|
|
|
this._httpClient = await this.CreateTestServerWithAgentResolutionAsync(
|
|
(Agent1Name, "Agent 1 instructions", Agent1Response),
|
|
(Agent2Name, "Agent 2 instructions", Agent2Response));
|
|
|
|
// Act - Create response for agent 1
|
|
using StringContent requestContent1 = new(JsonSerializer.Serialize(new
|
|
{
|
|
agent = new { name = Agent1Name },
|
|
input = new[]
|
|
{
|
|
new { type = "message", role = "user", content = "Test message" }
|
|
}
|
|
}), Encoding.UTF8, "application/json");
|
|
|
|
using HttpResponseMessage httpResponse1 = await this._httpClient!.PostAsync(new Uri("/v1/responses", UriKind.Relative), requestContent1);
|
|
|
|
// Act - Create response for agent 2
|
|
using StringContent requestContent2 = new(JsonSerializer.Serialize(new
|
|
{
|
|
agent = new { name = Agent2Name },
|
|
input = new[]
|
|
{
|
|
new { type = "message", role = "user", content = "Test message" }
|
|
}
|
|
}), Encoding.UTF8, "application/json");
|
|
|
|
using HttpResponseMessage httpResponse2 = await this._httpClient!.PostAsync(new Uri("/v1/responses", UriKind.Relative), requestContent2);
|
|
|
|
// Assert
|
|
string responseJson1 = await httpResponse1.Content.ReadAsStringAsync();
|
|
string responseJson2 = await httpResponse2.Content.ReadAsStringAsync();
|
|
|
|
using JsonDocument doc1 = JsonDocument.Parse(responseJson1);
|
|
using JsonDocument doc2 = JsonDocument.Parse(responseJson2);
|
|
|
|
string content1 = doc1.RootElement.GetProperty("output")[0].GetProperty("content")[0].GetProperty("text").GetString() ?? string.Empty;
|
|
string content2 = doc2.RootElement.GetProperty("output")[0].GetProperty("content")[0].GetProperty("text").GetString() ?? string.Empty;
|
|
|
|
Assert.Equal(Agent1Response, content1);
|
|
Assert.Equal(Agent2Response, content2);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that agent resolution using the metadata.entity_id property works correctly.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task CreateResponse_WithMetadataEntityId_ResolvesCorrectAgentAsync()
|
|
{
|
|
// Arrange
|
|
const string AgentName = "metadata-agent";
|
|
const string Instructions = "You are a helpful assistant.";
|
|
const string ExpectedResponse = "Response via metadata.entity_id";
|
|
|
|
this._httpClient = await this.CreateTestServerWithAgentResolutionAsync(
|
|
(AgentName, Instructions, ExpectedResponse));
|
|
|
|
// Act - Use raw HTTP request with metadata.entity_id
|
|
using StringContent requestContent = new(JsonSerializer.Serialize(new
|
|
{
|
|
metadata = new { entity_id = AgentName },
|
|
input = new[]
|
|
{
|
|
new { type = "message", role = "user", content = "Test message" }
|
|
}
|
|
}), Encoding.UTF8, "application/json");
|
|
|
|
using HttpResponseMessage httpResponse = await this._httpClient!.PostAsync(new Uri("/v1/responses", UriKind.Relative), requestContent);
|
|
|
|
// Assert
|
|
Assert.True(httpResponse.IsSuccessStatusCode, $"Request failed with status {httpResponse.StatusCode}");
|
|
|
|
string responseJson = await httpResponse.Content.ReadAsStringAsync();
|
|
using JsonDocument doc = JsonDocument.Parse(responseJson);
|
|
JsonElement root = doc.RootElement;
|
|
|
|
Assert.Equal("completed", root.GetProperty("status").GetString());
|
|
JsonElement outputArray = root.GetProperty("output");
|
|
Assert.True(outputArray.GetArrayLength() > 0);
|
|
|
|
JsonElement firstOutput = outputArray[0];
|
|
JsonElement contentArray = firstOutput.GetProperty("content");
|
|
JsonElement firstContent = contentArray[0];
|
|
string actualResponse = firstContent.GetProperty("text").GetString() ?? string.Empty;
|
|
|
|
Assert.Equal(ExpectedResponse, actualResponse);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that agent resolution fails gracefully when agent is not found.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task CreateResponse_WithNonExistentAgent_ReturnsNotFoundAsync()
|
|
{
|
|
// Arrange
|
|
this._httpClient = await this.CreateTestServerWithAgentResolutionAsync(
|
|
("existing-agent", "Instructions", "Response"));
|
|
|
|
// Act
|
|
using StringContent requestContent = new(JsonSerializer.Serialize(new
|
|
{
|
|
agent = new { name = "non-existent-agent" },
|
|
input = new[]
|
|
{
|
|
new { type = "message", role = "user", content = "Test message" }
|
|
}
|
|
}), Encoding.UTF8, "application/json");
|
|
|
|
using HttpResponseMessage httpResponse = await this._httpClient!.PostAsync(new Uri("/v1/responses", UriKind.Relative), requestContent);
|
|
|
|
// Assert
|
|
Assert.Equal(System.Net.HttpStatusCode.BadRequest, httpResponse.StatusCode);
|
|
|
|
string responseJson = await httpResponse.Content.ReadAsStringAsync();
|
|
Assert.Contains("non-existent-agent", responseJson);
|
|
Assert.Contains("not found", responseJson, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that agent resolution fails gracefully when no agent name is provided.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task CreateResponse_WithoutAgentOrModel_ReturnsBadRequestAsync()
|
|
{
|
|
// Arrange
|
|
this._httpClient = await this.CreateTestServerWithAgentResolutionAsync(
|
|
("test-agent", "Instructions", "Response"));
|
|
|
|
// Act - Use raw HTTP request without agent.name or model
|
|
using StringContent requestContent = new(JsonSerializer.Serialize(new
|
|
{
|
|
input = new[]
|
|
{
|
|
new { type = "message", role = "user", content = "Test message" }
|
|
}
|
|
}), Encoding.UTF8, "application/json");
|
|
|
|
using HttpResponseMessage httpResponse = await this._httpClient!.PostAsync(new Uri("/v1/responses", UriKind.Relative), requestContent);
|
|
|
|
// Assert
|
|
Assert.Equal(System.Net.HttpStatusCode.BadRequest, httpResponse.StatusCode);
|
|
|
|
string responseJson = await httpResponse.Content.ReadAsStringAsync();
|
|
using JsonDocument errorDoc1 = JsonDocument.Parse(responseJson);
|
|
string? errorCode = errorDoc1.RootElement.GetProperty("error").GetProperty("code").GetString();
|
|
Assert.Equal("missing_required_parameter", errorCode);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that the model field alone is not used for agent resolution.
|
|
/// The multi-agent endpoint requires agent.name or metadata.entity_id; setting only model returns 400.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task CreateResponse_WithModelOnly_ReturnsBadRequestAsync()
|
|
{
|
|
// Arrange
|
|
const string AgentName = "test-agent";
|
|
|
|
this._httpClient = await this.CreateTestServerWithAgentResolutionAsync(
|
|
(AgentName, "Instructions", "Response"));
|
|
|
|
// Act - Send request with model=agentName but no agent.name or metadata.entity_id
|
|
using StringContent requestContent = new(JsonSerializer.Serialize(new
|
|
{
|
|
model = AgentName,
|
|
input = new[]
|
|
{
|
|
new { type = "message", role = "user", content = "Test message" }
|
|
}
|
|
}), Encoding.UTF8, "application/json");
|
|
|
|
using HttpResponseMessage httpResponse = await this._httpClient!.PostAsync(new Uri("/v1/responses", UriKind.Relative), requestContent);
|
|
|
|
// Assert - model is not used for agent resolution
|
|
Assert.Equal(System.Net.HttpStatusCode.BadRequest, httpResponse.StatusCode);
|
|
|
|
string responseJson = await httpResponse.Content.ReadAsStringAsync();
|
|
using JsonDocument errorDoc2 = JsonDocument.Parse(responseJson);
|
|
string? errorCode = errorDoc2.RootElement.GetProperty("error").GetProperty("code").GetString();
|
|
Assert.Equal("missing_required_parameter", errorCode);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that agent resolution prioritizes agent.name over model when both are provided.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task CreateResponse_WithBothAgentAndModel_UsesAgentNameAsync()
|
|
{
|
|
// Arrange
|
|
const string Agent1Name = "agent-1";
|
|
const string Agent1Response = "Response from agent 1";
|
|
const string Agent2Name = "agent-2";
|
|
const string Agent2Response = "Response from agent 2";
|
|
|
|
this._httpClient = await this.CreateTestServerWithAgentResolutionAsync(
|
|
(Agent1Name, "Agent 1 instructions", Agent1Response),
|
|
(Agent2Name, "Agent 2 instructions", Agent2Response));
|
|
|
|
// Act - Use raw HTTP request with both agent.name and model
|
|
using StringContent requestContent = new(JsonSerializer.Serialize(new
|
|
{
|
|
agent = new { name = Agent1Name },
|
|
model = Agent2Name,
|
|
input = new[]
|
|
{
|
|
new { type = "message", role = "user", content = "Test message" }
|
|
}
|
|
}), Encoding.UTF8, "application/json");
|
|
|
|
using HttpResponseMessage httpResponse = await this._httpClient!.PostAsync(new Uri("/v1/responses", UriKind.Relative), requestContent);
|
|
|
|
// Assert
|
|
Assert.True(httpResponse.IsSuccessStatusCode);
|
|
|
|
string responseJson = await httpResponse.Content.ReadAsStringAsync();
|
|
using JsonDocument doc = JsonDocument.Parse(responseJson);
|
|
JsonElement root = doc.RootElement;
|
|
|
|
JsonElement outputArray = root.GetProperty("output");
|
|
JsonElement firstOutput = outputArray[0];
|
|
JsonElement contentArray = firstOutput.GetProperty("content");
|
|
JsonElement firstContent = contentArray[0];
|
|
string actualResponse = firstContent.GetProperty("text").GetString() ?? string.Empty;
|
|
|
|
// Should use agent.name (Agent1Name) and return Agent1Response
|
|
Assert.Equal(Agent1Response, actualResponse);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that streaming and non-streaming work correctly with agent resolution.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task CreateResponse_AgentResolution_StreamingAndNonStreamingBothWorkAsync()
|
|
{
|
|
// Arrange
|
|
const string AgentName = "dual-mode-agent";
|
|
const string Instructions = "You are a helpful assistant.";
|
|
const string ExpectedResponse = "This is the response";
|
|
|
|
this._httpClient = await this.CreateTestServerWithAgentResolutionAsync(
|
|
(AgentName, Instructions, ExpectedResponse));
|
|
|
|
// Act - Non-streaming
|
|
using StringContent nonStreamingRequest = new(JsonSerializer.Serialize(new
|
|
{
|
|
agent = new { name = AgentName },
|
|
input = new[]
|
|
{
|
|
new { type = "message", role = "user", content = "Test message" }
|
|
}
|
|
}), Encoding.UTF8, "application/json");
|
|
|
|
using HttpResponseMessage nonStreamingHttpResponse = await this._httpClient!.PostAsync(new Uri("/v1/responses", UriKind.Relative), nonStreamingRequest);
|
|
|
|
// Act - Streaming
|
|
using StringContent streamingRequest = new(JsonSerializer.Serialize(new
|
|
{
|
|
agent = new { name = AgentName },
|
|
stream = true,
|
|
input = new[]
|
|
{
|
|
new { type = "message", role = "user", content = "Test message" }
|
|
}
|
|
}), Encoding.UTF8, "application/json");
|
|
|
|
using HttpResponseMessage streamingHttpResponse = await this._httpClient!.PostAsync(new Uri("/v1/responses", UriKind.Relative), streamingRequest);
|
|
|
|
// Assert non-streaming
|
|
string nonStreamingJson = await nonStreamingHttpResponse.Content.ReadAsStringAsync();
|
|
using JsonDocument nonStreamingDoc = JsonDocument.Parse(nonStreamingJson);
|
|
string nonStreamingContent = nonStreamingDoc.RootElement.GetProperty("output")[0].GetProperty("content")[0].GetProperty("text").GetString() ?? string.Empty;
|
|
|
|
// Assert streaming
|
|
string streamingText = await streamingHttpResponse.Content.ReadAsStringAsync();
|
|
|
|
Assert.Equal(ExpectedResponse, nonStreamingContent);
|
|
Assert.Contains(ExpectedResponse, streamingText);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that the agent.name field is populated in the response.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task CreateResponse_WithAgentName_ResponseIncludesAgentFieldAsync()
|
|
{
|
|
// Arrange
|
|
const string AgentName = "test-agent";
|
|
const string Instructions = "You are a helpful assistant.";
|
|
const string ExpectedResponse = "Hello";
|
|
|
|
this._httpClient = await this.CreateTestServerWithAgentResolutionAsync(
|
|
(AgentName, Instructions, ExpectedResponse));
|
|
|
|
// Act
|
|
using StringContent requestContent = new(JsonSerializer.Serialize(new
|
|
{
|
|
agent = new { name = AgentName },
|
|
input = new[]
|
|
{
|
|
new { type = "message", role = "user", content = "Test message" }
|
|
}
|
|
}), Encoding.UTF8, "application/json");
|
|
|
|
using HttpResponseMessage httpResponse = await this._httpClient!.PostAsync(new Uri("/v1/responses", UriKind.Relative), requestContent);
|
|
|
|
// Assert
|
|
Assert.True(httpResponse.IsSuccessStatusCode);
|
|
|
|
string responseJson = await httpResponse.Content.ReadAsStringAsync();
|
|
using JsonDocument doc = JsonDocument.Parse(responseJson);
|
|
JsonElement root = doc.RootElement;
|
|
|
|
// Verify the response includes the agent field
|
|
if (root.TryGetProperty("agent", out JsonElement agentElement))
|
|
{
|
|
string? agentNameInResponse = agentElement.GetProperty("name").GetString();
|
|
Assert.Equal(AgentName, agentNameInResponse);
|
|
}
|
|
}
|
|
|
|
private async Task<HttpClient> CreateTestServerWithAgentResolutionAsync(
|
|
params (string Name, string Instructions, string ResponseText)[] agents)
|
|
{
|
|
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
|
builder.WebHost.UseTestServer();
|
|
|
|
foreach ((string name, string instructions, string responseText) in agents)
|
|
{
|
|
IChatClient mockChatClient = new TestHelpers.SimpleMockChatClient(responseText);
|
|
builder.Services.AddKeyedSingleton($"chat-client-{name}", mockChatClient);
|
|
builder.AddAIAgent(name, instructions, chatClientServiceKey: $"chat-client-{name}");
|
|
}
|
|
|
|
builder.AddOpenAIResponses();
|
|
|
|
this._app = builder.Build();
|
|
|
|
// Use the agent resolution variant - MapOpenAIResponses() without agent parameter
|
|
this._app.MapOpenAIResponses();
|
|
|
|
await this._app.StartAsync();
|
|
|
|
TestServer testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
|
|
?? throw new InvalidOperationException("TestServer not found");
|
|
|
|
return testServer.CreateClient();
|
|
}
|
|
}
|