mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Add dedicated Foundry.Hosting UnitTest project (#5592)
* Foundry.Hosting.UnitTests: extract project from Foundry.UnitTests Move all Hosting/* tests, three toolbox TestData JSONs, and the FakeAuthenticationTokenProvider/HttpHandlerAssert/TestDataUtil helpers (trimmed to toolbox getters) into a new Microsoft.Agents.AI.Foundry.Hosting.UnitTests project. Add it to the slnx and grant the new assembly InternalsVisibleTo from Microsoft.Agents.AI.Foundry and Microsoft.Agents.AI.Foundry.Hosting. * Foundry.Hosting.UnitTests: align namespaces to assembly name Rename namespaces from Microsoft.Agents.AI.Foundry.UnitTests(.Hosting) to Microsoft.Agents.AI.Foundry.Hosting.UnitTests across all moved tests, the duplicated helpers, and the trimmed TestDataUtil. Also fixes the prior namespace inconsistency in FoundryToolboxTests. * Foundry.Hosting.UnitTests: split WorkflowIntegrationTests by SUT Replace the WorkflowIntegrationTests file (an IT-named file inside a UT project) with two SUT-focused files plus a shared test-doubles file: - AgentFrameworkResponseHandlerWorkflowTests.cs - the 5 handler-driven tests that exercise AgentFrameworkResponseHandler with a real workflow agent. - OutputConverterWorkflowTests.cs - the 5 OutputConverter tests driven by hand-crafted update sequences mirroring real workflow patterns. - WorkflowTestAgents.cs - StreamingTextAgent and ThrowingStreamingAgent extracted as internal types used by both files. * Foundry.UnitTests: trim Hosting-related conditionals and dead testdata Now that Hosting tests live in their own project: - drop the Compile Remove guard for the Hosting subfolder, - drop the .NETCoreApp-only PackageReferences (Azure.AI.AgentServer.Responses, Microsoft.AspNetCore.TestHost, OpenTelemetry, OpenTelemetry.Exporter.InMemory), - drop the conditional ProjectReference to Microsoft.Agents.AI.Foundry.Hosting, - delete the three Toolbox JSON files and the matching Toolbox getters in TestDataUtil. * Foundry.Hosting.UnitTests: drop redundant 'using Microsoft.Agents.AI.Foundry.Hosting' The new project namespace is Microsoft.Agents.AI.Foundry.Hosting.UnitTests, which already brings the parent Microsoft.Agents.AI.Foundry.Hosting namespace into scope. The explicit using statement is therefore redundant (IDE0005). Caught by 'dotnet format --verify-no-changes' running on Linux against the .NET 10 SDK. * Foundry.Hosting: drop InternalsVisibleTo to Foundry.UnitTests The non-hosting Foundry.UnitTests project no longer holds any Hosting tests after the split, so it doesn't need access to internal types in Microsoft.Agents.AI.Foundry.Hosting. Only Microsoft.Agents.AI.Foundry.Hosting.UnitTests needs it. * Foundry.Hosting: rename DelegatingResponsesClient to UserAgentResponsesClient Address westey-m's review feedback on PR #5453: `Delegating*` is conventionally reserved for inheritable base classes (mirroring `DelegatingHandler`) where consumers override one or two members. This polyfill is sealed and only injects the User-Agent supplement, so the new name reflects its actual purpose. Renamed via `git mv` to preserve history: * `src/Microsoft.Agents.AI.Foundry.Hosting/DelegatingResponsesClient.cs` to `UserAgentResponsesClient.cs` * `tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/DelegatingResponsesClientTests.cs` to `UserAgentResponsesClientTests.cs` Class, constructor, and all references updated across: * `src/.../UserAgentResponsesClient.cs` (class + constructor + internal log message) * `src/.../ServiceCollectionExtensions.cs` (cref + type check + instantiation) * `src/.../HostedAgentUserAgentPolicy.cs` (cref) * `tests/Foundry.UnitTests/RequestOptionsExtensionsTests.cs` (comment) * `tests/Foundry.Hosting.UnitTests/UserAgentResponsesClientTests.cs` (class + cref + instantiations)
This commit is contained in:
committed by
GitHub
Unverified
parent
317ef4491e
commit
fb97e93a01
@@ -592,6 +592,7 @@
|
||||
<Project Path="tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj" />
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This policy is added at request time (per-call <see cref="PipelinePosition"/>)
|
||||
/// by <see cref="DelegatingResponsesClient"/> when invoking the wrapped
|
||||
/// by <see cref="UserAgentResponsesClient"/> when invoking the wrapped
|
||||
/// <see cref="OpenAI.Responses.ResponsesClient"/>. It is only registered when an agent is
|
||||
/// resolved by the Foundry hosting layer.
|
||||
/// </para>
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Foundry.UnitTests" />
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Foundry.Hosting.UnitTests" />
|
||||
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -210,7 +210,7 @@ public static class FoundryHostingExtensions
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to wrap the agent's underlying <see cref="ResponsesClient"/>
|
||||
/// with a <see cref="DelegatingResponsesClient"/> so every outgoing Responses-API request
|
||||
/// with a <see cref="UserAgentResponsesClient"/> so every outgoing Responses-API request
|
||||
/// carries the hosted-agent <c>User-Agent</c> segment.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -219,7 +219,7 @@ public static class FoundryHostingExtensions
|
||||
/// <list type="bullet">
|
||||
/// <item><description><paramref name="agent"/> exposes no <see cref="IChatClient"/>;</description></item>
|
||||
/// <item><description>the chat client is not backed by MEAI's internal <c>OpenAIResponsesChatClient</c> (e.g., a non-OpenAI provider or a custom impl);</description></item>
|
||||
/// <item><description>the inner <see cref="ResponsesClient"/> is already a <see cref="DelegatingResponsesClient"/>.</description></item>
|
||||
/// <item><description>the inner <see cref="ResponsesClient"/> is already a <see cref="UserAgentResponsesClient"/>.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -261,12 +261,12 @@ public static class FoundryHostingExtensions
|
||||
}
|
||||
|
||||
var current = field.GetValue(meaiInstance) as ResponsesClient;
|
||||
if (current is null or DelegatingResponsesClient)
|
||||
if (current is null or UserAgentResponsesClient)
|
||||
{
|
||||
return agent;
|
||||
}
|
||||
|
||||
field.SetValue(meaiInstance, new DelegatingResponsesClient(current));
|
||||
field.SetValue(meaiInstance, new UserAgentResponsesClient(current));
|
||||
return agent;
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -33,11 +33,11 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
/// never expected to run; the throwing transport surfaces any unexpected escape route loudly.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class DelegatingResponsesClient : ResponsesClient
|
||||
internal sealed class UserAgentResponsesClient : ResponsesClient
|
||||
{
|
||||
private readonly ResponsesClient _inner;
|
||||
|
||||
public DelegatingResponsesClient(ResponsesClient inner)
|
||||
public UserAgentResponsesClient(ResponsesClient inner)
|
||||
: base(BuildDummyPipeline(), new OpenAIClientOptions { Endpoint = inner?.Endpoint })
|
||||
{
|
||||
this._inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
@@ -104,7 +104,7 @@ internal sealed class DelegatingResponsesClient : ResponsesClient
|
||||
private sealed class ThrowingTransport : PipelineTransport
|
||||
{
|
||||
private const string Message =
|
||||
"DelegatingResponsesClient transport invoked bypassed the override-and-delegate design. This exception should be unreachable and should never be thrown following the correct usage of DelegatingResponsesClient.";
|
||||
"UserAgentResponsesClient transport invoked bypassed the override-and-delegate design. This exception should be unreachable and should never be thrown following the correct usage of UserAgentResponsesClient.";
|
||||
|
||||
protected override PipelineMessage CreateMessageCore() => throw new InvalidOperationException(Message);
|
||||
protected override void ProcessCore(PipelineMessage message) => throw new InvalidOperationException(Message);
|
||||
@@ -53,6 +53,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Foundry.UnitTests" />
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Foundry.Hosting.UnitTests" />
|
||||
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
+1
-2
@@ -10,7 +10,6 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -19,7 +18,7 @@ using OpenTelemetry;
|
||||
using OpenTelemetry.Trace;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that verify OTel spans are actually emitted and captured through the
|
||||
+1
-2
@@ -9,7 +9,6 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -17,7 +16,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
public class AgentFrameworkResponseHandlerTests
|
||||
{
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AgentFrameworkResponseHandler"/> that verify behavior
|
||||
/// when the registered agent is a workflow-backed <see cref="AIAgent"/>. These exercise
|
||||
/// real workflow builders and the in-process execution environment to drive the handler
|
||||
/// through realistic streaming event patterns.
|
||||
/// </summary>
|
||||
public class AgentFrameworkResponseHandlerWorkflowTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SequentialWorkflow_SingleAgent_ProducesTextOutputAsync()
|
||||
{
|
||||
// Arrange: single-agent sequential workflow
|
||||
var echoAgent = new StreamingTextAgent("echo", "Hello from the workflow!");
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential("test-sequential", echoAgent);
|
||||
var workflowAgent = workflow.AsAIAgent(
|
||||
id: "workflow-agent",
|
||||
name: "Test Workflow",
|
||||
executionEnvironment: InProcessExecution.OffThread,
|
||||
includeExceptionDetails: true);
|
||||
|
||||
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Hello");
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: should have lifecycle events + at least one text output + terminal
|
||||
Assert.IsType<ResponseCreatedEvent>(events[0]);
|
||||
Assert.IsType<ResponseInProgressEvent>(events[1]);
|
||||
Assert.True(events.Count >= 4, $"Expected at least 4 events, got {events.Count}");
|
||||
|
||||
var lastEvent = events[^1];
|
||||
Assert.True(
|
||||
lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent,
|
||||
$"Expected terminal event, got {lastEvent.GetType().Name}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SequentialWorkflow_TwoAgents_ProducesOutputFromBothAsync()
|
||||
{
|
||||
// Arrange: two agents in sequence
|
||||
var agent1 = new StreamingTextAgent("agent1", "First agent says hello");
|
||||
var agent2 = new StreamingTextAgent("agent2", "Second agent says goodbye");
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential("test-sequential-2", agent1, agent2);
|
||||
var workflowAgent = workflow.AsAIAgent(
|
||||
id: "seq-workflow",
|
||||
name: "Sequential Workflow",
|
||||
executionEnvironment: InProcessExecution.OffThread,
|
||||
includeExceptionDetails: true);
|
||||
|
||||
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Process this");
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: should have workflow action events for executor lifecycle
|
||||
var lastEvent = events[^1];
|
||||
Assert.True(
|
||||
lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent,
|
||||
$"Expected terminal event, got {lastEvent.GetType().Name}");
|
||||
|
||||
// Should have output item events (either text messages or workflow actions)
|
||||
Assert.True(events.OfType<ResponseOutputItemAddedEvent>().Any(),
|
||||
"Expected at least one output item from the workflow");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Workflow_AgentThrowsException_ProducesErrorOutputAsync()
|
||||
{
|
||||
// Arrange: workflow with an agent that throws
|
||||
var throwingAgent = new ThrowingStreamingAgent("thrower", new InvalidOperationException("Agent crashed"));
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential("test-error", throwingAgent);
|
||||
var workflowAgent = workflow.AsAIAgent(
|
||||
id: "error-workflow",
|
||||
name: "Error Workflow",
|
||||
executionEnvironment: InProcessExecution.OffThread,
|
||||
includeExceptionDetails: true);
|
||||
|
||||
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Trigger error");
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: should have lifecycle events + error/failure indicator
|
||||
Assert.IsType<ResponseCreatedEvent>(events[0]);
|
||||
Assert.IsType<ResponseInProgressEvent>(events[1]);
|
||||
|
||||
var lastEvent = events[^1];
|
||||
// Workflow errors surface as either Failed or Completed (depending on error handling)
|
||||
Assert.True(
|
||||
lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent,
|
||||
$"Expected terminal event, got {lastEvent.GetType().Name}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Workflow_ExecutorEvents_ProduceWorkflowActionItemsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = new StreamingTextAgent("test-agent", "Result");
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential("test-actions", agent);
|
||||
var workflowAgent = workflow.AsAIAgent(
|
||||
id: "actions-workflow",
|
||||
name: "Actions Workflow",
|
||||
executionEnvironment: InProcessExecution.OffThread);
|
||||
|
||||
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Hello");
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: workflow should produce OutputItemAdded events for executor lifecycle
|
||||
var addedEvents = events.OfType<ResponseOutputItemAddedEvent>().ToList();
|
||||
Assert.True(addedEvents.Count >= 1,
|
||||
$"Expected at least 1 output item added event, got {addedEvents.Count}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WorkflowAgent_RegisteredWithKey_ResolvesCorrectlyAsync()
|
||||
{
|
||||
// Arrange: workflow agent registered with a keyed service name
|
||||
var agent = new StreamingTextAgent("inner", "Keyed workflow response");
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential("keyed-wf", agent);
|
||||
var workflowAgent = workflow.AsAIAgent(
|
||||
id: "keyed-workflow",
|
||||
name: "Keyed Workflow",
|
||||
executionEnvironment: InProcessExecution.OffThread);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddKeyedSingleton("my-workflow", workflowAgent);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("my-workflow") };
|
||||
request.Input = CreateUserInput("Test keyed workflow");
|
||||
var mockContext = CreateMockContext();
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, mockContext.Object);
|
||||
|
||||
// Assert
|
||||
Assert.IsType<ResponseCreatedEvent>(events[0]);
|
||||
Assert.True(events.Count >= 3, $"Expected at least 3 events, got {events.Count}");
|
||||
}
|
||||
|
||||
private static (AgentFrameworkResponseHandler handler, CreateResponse request, ResponseContext context)
|
||||
CreateHandlerWithAgent(AIAgent agent, string userMessage)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton(agent);
|
||||
services.AddSingleton<ILogger<AgentFrameworkResponseHandler>>(NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = CreateUserInput(userMessage);
|
||||
var mockContext = CreateMockContext();
|
||||
|
||||
return (handler, request, mockContext.Object);
|
||||
}
|
||||
|
||||
private static BinaryData CreateUserInput(string text)
|
||||
{
|
||||
return BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_in_1", status = "completed", role = "user",
|
||||
content = new[] { new { type = "input_text", text } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Mock<ResponseContext> CreateMockContext()
|
||||
{
|
||||
var mock = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mock.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
mock.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<Item>());
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static async Task<List<ResponseStreamEvent>> CollectEventsAsync(
|
||||
AgentFrameworkResponseHandler handler,
|
||||
CreateResponse request,
|
||||
ResponseContext context)
|
||||
{
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in handler.CreateAsync(request, context, CancellationToken.None))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
internal sealed class FakeAuthenticationTokenProvider : AuthenticationTokenProvider
|
||||
{
|
||||
public override GetTokenOptions? CreateTokenOptions(IReadOnlyDictionary<string, object> properties)
|
||||
{
|
||||
return new GetTokenOptions(new Dictionary<string, object>());
|
||||
}
|
||||
|
||||
public override AuthenticationToken GetToken(GetTokenOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
return new AuthenticationToken("token-value", "token-type", DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
|
||||
public override ValueTask<AuthenticationToken> GetTokenAsync(GetTokenOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
return new ValueTask<AuthenticationToken>(this.GetToken(options, cancellationToken));
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -1,9 +1,8 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
public class FoundryAIToolExtensionsTests
|
||||
{
|
||||
+1
-2
@@ -6,10 +6,9 @@ using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Core;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
public class FoundryToolboxBearerTokenHandlerTests
|
||||
{
|
||||
+1
-2
@@ -4,11 +4,10 @@ using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Core;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
public class FoundryToolboxServiceTests
|
||||
{
|
||||
+1
-2
@@ -9,13 +9,12 @@ using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
#pragma warning disable AAIP001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="FoundryToolbox"/> class.
|
||||
+1
-2
@@ -9,7 +9,6 @@ using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
@@ -18,7 +17,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
#pragma warning disable OPENAI001, SCME0001, SCME0002, MEAI001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end tests that exercise the FULL hosted ASP.NET Core pipeline:
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
internal sealed class HttpHandlerAssert : HttpClientHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage>? _assertion;
|
||||
private readonly Func<HttpRequestMessage, Task<HttpResponseMessage>>? _assertionAsync;
|
||||
|
||||
public HttpHandlerAssert(Func<HttpRequestMessage, HttpResponseMessage> assertion)
|
||||
{
|
||||
this._assertion = assertion;
|
||||
}
|
||||
public HttpHandlerAssert(Func<HttpRequestMessage, Task<HttpResponseMessage>> assertionAsync)
|
||||
{
|
||||
this._assertionAsync = assertionAsync;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (this._assertionAsync is not null)
|
||||
{
|
||||
return await this._assertionAsync.Invoke(request);
|
||||
}
|
||||
|
||||
return this._assertion!.Invoke(request);
|
||||
}
|
||||
|
||||
#if NET
|
||||
protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return this._assertion!(request);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
+1
-2
@@ -3,11 +3,10 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
public class InputConverterTests
|
||||
{
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<NoWarn>$(NoWarn);NU1605;NU1903</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
<PackageReference Include="Azure.AI.AgentServer.Responses" />
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
|
||||
<PackageReference Include="OpenTelemetry" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="TestData\ToolboxRecordResponse.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="TestData\ToolboxVersionResponse.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="TestData\ToolboxVersionWithDecorationFields.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+1
-2
@@ -7,13 +7,12 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
public class OutputConverterTests
|
||||
{
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="OutputConverter"/> driven directly by hand-crafted update
|
||||
/// sequences that mirror the patterns produced by real workflow executions
|
||||
/// (sequential, group chat, code executor, sub-workflow, mixed content types).
|
||||
/// </summary>
|
||||
public class OutputConverterWorkflowTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SequentialWorkflowPattern_ProducesCorrectEventsAsync()
|
||||
{
|
||||
// Simulate what WorkflowSession produces for a 2-agent sequential workflow
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
{
|
||||
// Superstep 1: Agent 1
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") },
|
||||
new AgentResponseUpdate { MessageId = "msg_a1", Contents = [new MeaiTextContent("Agent 1 output")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
|
||||
// Superstep 2: Agent 2
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_2", "start") },
|
||||
new AgentResponseUpdate { MessageId = "msg_a2", Contents = [new MeaiTextContent("Agent 2 output")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_2", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// 4 workflow action items + 2 text messages = 6 output items
|
||||
Assert.Equal(6, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Equal(2, events.OfType<ResponseTextDeltaEvent>().Count());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GroupChatPattern_ProducesCorrectEventsAsync()
|
||||
{
|
||||
// Simulate round-robin group chat: agent1 → agent2 → agent1 → terminate
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
{
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_1", "turn") },
|
||||
new AgentResponseUpdate { MessageId = "msg_gc_1", Contents = [new MeaiTextContent("Agent 1 turn 1")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_1", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_2", "turn") },
|
||||
new AgentResponseUpdate { MessageId = "msg_gc_2", Contents = [new MeaiTextContent("Agent 2 turn 1")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_2", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(3) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_1", "turn") },
|
||||
new AgentResponseUpdate { MessageId = "msg_gc_3", Contents = [new MeaiTextContent("Agent 1 turn 2")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_1", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(3) },
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// 6 workflow actions + 3 text messages = 9 output items
|
||||
Assert.Equal(9, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Equal(3, events.OfType<ResponseTextDeltaEvent>().Count());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CodeExecutorPattern_ProducesCorrectEventsAsync()
|
||||
{
|
||||
// Simulate a code-based FunctionExecutor: invoked → completed, no text content
|
||||
// (code executors don't produce AgentResponseUpdateEvent, just executor lifecycle)
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
{
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("uppercase_fn", "hello") },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("uppercase_fn", "HELLO") },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
|
||||
// Second executor uses the output
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("format_agent", "start") },
|
||||
new AgentResponseUpdate { MessageId = "msg_fmt", Contents = [new MeaiTextContent("Formatted: HELLO")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("format_agent", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// 4 workflow actions + 1 text message = 5 output items
|
||||
Assert.Equal(5, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Single(events.OfType<ResponseTextDeltaEvent>());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubworkflowPattern_ProducesCorrectEventsAsync()
|
||||
{
|
||||
// Simulate a parent workflow that invokes a sub-workflow executor
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
{
|
||||
new AgentResponseUpdate { RawRepresentation = new WorkflowStartedEvent("parent") },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
|
||||
// Sub-workflow executor invoked
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("sub_workflow_host", "start") },
|
||||
// Inner agent within sub-workflow produces text (unwrapped by WorkflowSession)
|
||||
new AgentResponseUpdate { MessageId = "msg_sub_1", Contents = [new MeaiTextContent("Sub-workflow agent output")] },
|
||||
// Sub-workflow executor completed
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("sub_workflow_host", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// 2 workflow actions + 1 text message = 3 output items
|
||||
Assert.Equal(3, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Single(events.OfType<ResponseTextDeltaEvent>());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WorkflowWithMultipleContentTypes_HandlesAllCorrectlyAsync()
|
||||
{
|
||||
// Simulate a workflow producing reasoning, text, function calls, and usage
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
{
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("planner", "start") },
|
||||
// Reasoning
|
||||
new AgentResponseUpdate { Contents = [new TextReasoningContent("Let me think about this...")] },
|
||||
// Function call (tool use)
|
||||
new AgentResponseUpdate
|
||||
{
|
||||
Contents = [new FunctionCallContent("call_search", "web_search",
|
||||
new Dictionary<string, object?> { ["query"] = "latest news" })]
|
||||
},
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("planner", null) },
|
||||
// Next executor uses tool result
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("writer", "start") },
|
||||
new AgentResponseUpdate { MessageId = "msg_w1", Contents = [new MeaiTextContent("Based on my research, ")] },
|
||||
new AgentResponseUpdate { MessageId = "msg_w1", Contents = [new MeaiTextContent("here are the findings.")] },
|
||||
new AgentResponseUpdate
|
||||
{
|
||||
Contents = [new UsageContent(new UsageDetails { InputTokenCount = 500, OutputTokenCount = 200, TotalTokenCount = 700 })]
|
||||
},
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("writer", null) },
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Workflow actions: 4 (2 invoked + 2 completed)
|
||||
// Content: 1 reasoning + 1 function call + 1 text message = 3
|
||||
// Total: 7 output items
|
||||
Assert.Equal(7, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Contains(events, e => e is ResponseFunctionCallArgumentsDoneEvent);
|
||||
Assert.Equal(2, events.OfType<ResponseTextDeltaEvent>().Count());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
private static (ResponseEventStream stream, Mock<ResponseContext> mockContext) CreateTestStream()
|
||||
{
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
var request = new CreateResponse { Model = "test-model" };
|
||||
var stream = new ResponseEventStream(mockContext.Object, request);
|
||||
return (stream, mockContext);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<T> ToAsync<T>(IEnumerable<T> source)
|
||||
{
|
||||
foreach (var item in source)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -3,13 +3,12 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
public class ServiceCollectionExtensionsTests
|
||||
{
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.IO;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Utility class for loading toolbox-related test data files.
|
||||
/// </summary>
|
||||
internal static class TestDataUtil
|
||||
{
|
||||
private static readonly string s_toolboxRecordResponseJson = File.ReadAllText("TestData/ToolboxRecordResponse.json");
|
||||
private static readonly string s_toolboxVersionResponseJson = File.ReadAllText("TestData/ToolboxVersionResponse.json");
|
||||
private static readonly string s_toolboxVersionWithDecorationFieldsJson = File.ReadAllText("TestData/ToolboxVersionWithDecorationFields.json");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the toolbox record response JSON.
|
||||
/// </summary>
|
||||
public static string GetToolboxRecordResponseJson() => s_toolboxRecordResponseJson;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the toolbox version response JSON.
|
||||
/// </summary>
|
||||
public static string GetToolboxVersionResponseJson() => s_toolboxVersionResponseJson;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the toolbox version response JSON with decoration fields on tools.
|
||||
/// </summary>
|
||||
public static string GetToolboxVersionWithDecorationFieldsJson() => s_toolboxVersionWithDecorationFieldsJson;
|
||||
}
|
||||
+5
-6
@@ -11,23 +11,22 @@ using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001, SCME0001, SCME0002, MEAI001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="DelegatingResponsesClient"/> preserves user-supplied client options
|
||||
/// Verifies that <see cref="UserAgentResponsesClient"/> preserves user-supplied client options
|
||||
/// (Transport, RetryPolicy, UserAgentApplicationId, OrganizationId, ProjectId) and adds the
|
||||
/// hosted-agent User-Agent supplement on every outgoing request, including streaming.
|
||||
/// Covers both the Azure-flavored <see cref="ProjectResponsesClient"/> and the native OpenAI
|
||||
/// <see cref="ResponsesClient"/>.
|
||||
/// </summary>
|
||||
public sealed partial class DelegatingResponsesClientTests
|
||||
public sealed partial class UserAgentResponsesClientTests
|
||||
{
|
||||
private const string TestEndpoint = "https://fake-foundry.example.com/api/projects/fake-prj";
|
||||
private const string OpenAIEndpoint = "https://fake-openai.example.com/v1";
|
||||
@@ -215,7 +214,7 @@ public sealed partial class DelegatingResponsesClientTests
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildOpenAIInner(httpClient, userAgentApplicationId: "MY_APP_ID");
|
||||
var wrapper = new DelegatingResponsesClient(inner);
|
||||
var wrapper = new UserAgentResponsesClient(inner);
|
||||
|
||||
// Act
|
||||
switch (method)
|
||||
@@ -326,7 +325,7 @@ public sealed partial class DelegatingResponsesClientTests
|
||||
IChatClient meai = inner.AsIChatClient(Deployment);
|
||||
var meaiType = meai.GetType();
|
||||
var field = meaiType.GetField("_responseClient", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
field.SetValue(meai, new DelegatingResponsesClient(inner));
|
||||
field.SetValue(meai, new UserAgentResponsesClient(inner));
|
||||
return meai;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// A test agent that streams a single text update.
|
||||
/// </summary>
|
||||
internal sealed class StreamingTextAgent(string id, string responseText) : AIAgent
|
||||
{
|
||||
public new string Id => id;
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
yield return new AgentResponseUpdate
|
||||
{
|
||||
MessageId = $"msg_{id}",
|
||||
Contents = [new MeaiTextContent(responseText)]
|
||||
};
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A test agent that always throws an exception during streaming.
|
||||
/// </summary>
|
||||
internal sealed class ThrowingStreamingAgent(string id, Exception exception) : AIAgent
|
||||
{
|
||||
public new string Id => id;
|
||||
|
||||
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw exception;
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
-508
@@ -1,508 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests that verify workflow execution through the
|
||||
/// <see cref="AgentFrameworkResponseHandler"/> → <see cref="OutputConverter"/> pipeline.
|
||||
/// These use real workflow builders and the InProcessExecution environment
|
||||
/// to produce authentic streaming event patterns.
|
||||
/// </summary>
|
||||
public class WorkflowIntegrationTests
|
||||
{
|
||||
// ===== Sequential Workflow Tests =====
|
||||
|
||||
[Fact]
|
||||
public async Task SequentialWorkflow_SingleAgent_ProducesTextOutputAsync()
|
||||
{
|
||||
// Arrange: single-agent sequential workflow
|
||||
var echoAgent = new StreamingTextAgent("echo", "Hello from the workflow!");
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential("test-sequential", echoAgent);
|
||||
var workflowAgent = workflow.AsAIAgent(
|
||||
id: "workflow-agent",
|
||||
name: "Test Workflow",
|
||||
executionEnvironment: InProcessExecution.OffThread,
|
||||
includeExceptionDetails: true);
|
||||
|
||||
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Hello");
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: should have lifecycle events + at least one text output + terminal
|
||||
Assert.IsType<ResponseCreatedEvent>(events[0]);
|
||||
Assert.IsType<ResponseInProgressEvent>(events[1]);
|
||||
Assert.True(events.Count >= 4, $"Expected at least 4 events, got {events.Count}");
|
||||
|
||||
var lastEvent = events[^1];
|
||||
Assert.True(
|
||||
lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent,
|
||||
$"Expected terminal event, got {lastEvent.GetType().Name}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SequentialWorkflow_TwoAgents_ProducesOutputFromBothAsync()
|
||||
{
|
||||
// Arrange: two agents in sequence
|
||||
var agent1 = new StreamingTextAgent("agent1", "First agent says hello");
|
||||
var agent2 = new StreamingTextAgent("agent2", "Second agent says goodbye");
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential("test-sequential-2", agent1, agent2);
|
||||
var workflowAgent = workflow.AsAIAgent(
|
||||
id: "seq-workflow",
|
||||
name: "Sequential Workflow",
|
||||
executionEnvironment: InProcessExecution.OffThread,
|
||||
includeExceptionDetails: true);
|
||||
|
||||
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Process this");
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: should have workflow action events for executor lifecycle
|
||||
var lastEvent = events[^1];
|
||||
Assert.True(
|
||||
lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent,
|
||||
$"Expected terminal event, got {lastEvent.GetType().Name}");
|
||||
|
||||
// Should have output item events (either text messages or workflow actions)
|
||||
Assert.True(events.OfType<ResponseOutputItemAddedEvent>().Any(),
|
||||
"Expected at least one output item from the workflow");
|
||||
}
|
||||
|
||||
// ===== Workflow Error Propagation =====
|
||||
|
||||
[Fact]
|
||||
public async Task Workflow_AgentThrowsException_ProducesErrorOutputAsync()
|
||||
{
|
||||
// Arrange: workflow with an agent that throws
|
||||
var throwingAgent = new ThrowingStreamingAgent("thrower", new InvalidOperationException("Agent crashed"));
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential("test-error", throwingAgent);
|
||||
var workflowAgent = workflow.AsAIAgent(
|
||||
id: "error-workflow",
|
||||
name: "Error Workflow",
|
||||
executionEnvironment: InProcessExecution.OffThread,
|
||||
includeExceptionDetails: true);
|
||||
|
||||
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Trigger error");
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: should have lifecycle events + error/failure indicator
|
||||
Assert.IsType<ResponseCreatedEvent>(events[0]);
|
||||
Assert.IsType<ResponseInProgressEvent>(events[1]);
|
||||
|
||||
var lastEvent = events[^1];
|
||||
// Workflow errors surface as either Failed or Completed (depending on error handling)
|
||||
Assert.True(
|
||||
lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent,
|
||||
$"Expected terminal event, got {lastEvent.GetType().Name}");
|
||||
}
|
||||
|
||||
// ===== Workflow Action Lifecycle Events =====
|
||||
|
||||
[Fact]
|
||||
public async Task Workflow_ExecutorEvents_ProduceWorkflowActionItemsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = new StreamingTextAgent("test-agent", "Result");
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential("test-actions", agent);
|
||||
var workflowAgent = workflow.AsAIAgent(
|
||||
id: "actions-workflow",
|
||||
name: "Actions Workflow",
|
||||
executionEnvironment: InProcessExecution.OffThread);
|
||||
|
||||
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Hello");
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: workflow should produce OutputItemAdded events for executor lifecycle
|
||||
var addedEvents = events.OfType<ResponseOutputItemAddedEvent>().ToList();
|
||||
Assert.True(addedEvents.Count >= 1,
|
||||
$"Expected at least 1 output item added event, got {addedEvents.Count}");
|
||||
}
|
||||
|
||||
// ===== Keyed Workflow Registration =====
|
||||
|
||||
[Fact]
|
||||
public async Task WorkflowAgent_RegisteredWithKey_ResolvesCorrectlyAsync()
|
||||
{
|
||||
// Arrange: workflow agent registered with a keyed service name
|
||||
var agent = new StreamingTextAgent("inner", "Keyed workflow response");
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential("keyed-wf", agent);
|
||||
var workflowAgent = workflow.AsAIAgent(
|
||||
id: "keyed-workflow",
|
||||
name: "Keyed Workflow",
|
||||
executionEnvironment: InProcessExecution.OffThread);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddKeyedSingleton("my-workflow", workflowAgent);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("my-workflow") };
|
||||
request.Input = CreateUserInput("Test keyed workflow");
|
||||
var mockContext = CreateMockContext();
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, mockContext.Object);
|
||||
|
||||
// Assert
|
||||
Assert.IsType<ResponseCreatedEvent>(events[0]);
|
||||
Assert.True(events.Count >= 3, $"Expected at least 3 events, got {events.Count}");
|
||||
}
|
||||
|
||||
// ===== OutputConverter Direct Workflow Pattern Tests =====
|
||||
// These test the OutputConverter directly with update patterns that mirror real workflows.
|
||||
|
||||
[Fact]
|
||||
public async Task OutputConverter_SequentialWorkflowPattern_ProducesCorrectEventsAsync()
|
||||
{
|
||||
// Simulate what WorkflowSession produces for a 2-agent sequential workflow
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
{
|
||||
// Superstep 1: Agent 1
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") },
|
||||
new AgentResponseUpdate { MessageId = "msg_a1", Contents = [new MeaiTextContent("Agent 1 output")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
|
||||
// Superstep 2: Agent 2
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_2", "start") },
|
||||
new AgentResponseUpdate { MessageId = "msg_a2", Contents = [new MeaiTextContent("Agent 2 output")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_2", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// 4 workflow action items + 2 text messages = 6 output items
|
||||
Assert.Equal(6, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Equal(2, events.OfType<ResponseTextDeltaEvent>().Count());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OutputConverter_GroupChatPattern_ProducesCorrectEventsAsync()
|
||||
{
|
||||
// Simulate round-robin group chat: agent1 → agent2 → agent1 → terminate
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
{
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_1", "turn") },
|
||||
new AgentResponseUpdate { MessageId = "msg_gc_1", Contents = [new MeaiTextContent("Agent 1 turn 1")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_1", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_2", "turn") },
|
||||
new AgentResponseUpdate { MessageId = "msg_gc_2", Contents = [new MeaiTextContent("Agent 2 turn 1")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_2", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(3) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_1", "turn") },
|
||||
new AgentResponseUpdate { MessageId = "msg_gc_3", Contents = [new MeaiTextContent("Agent 1 turn 2")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_1", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(3) },
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// 6 workflow actions + 3 text messages = 9 output items
|
||||
Assert.Equal(9, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Equal(3, events.OfType<ResponseTextDeltaEvent>().Count());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OutputConverter_CodeExecutorPattern_ProducesCorrectEventsAsync()
|
||||
{
|
||||
// Simulate a code-based FunctionExecutor: invoked → completed, no text content
|
||||
// (code executors don't produce AgentResponseUpdateEvent, just executor lifecycle)
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
{
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("uppercase_fn", "hello") },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("uppercase_fn", "HELLO") },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
|
||||
// Second executor uses the output
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("format_agent", "start") },
|
||||
new AgentResponseUpdate { MessageId = "msg_fmt", Contents = [new MeaiTextContent("Formatted: HELLO")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("format_agent", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// 4 workflow actions + 1 text message = 5 output items
|
||||
Assert.Equal(5, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Single(events.OfType<ResponseTextDeltaEvent>());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OutputConverter_SubworkflowPattern_ProducesCorrectEventsAsync()
|
||||
{
|
||||
// Simulate a parent workflow that invokes a sub-workflow executor
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
{
|
||||
new AgentResponseUpdate { RawRepresentation = new WorkflowStartedEvent("parent") },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
|
||||
// Sub-workflow executor invoked
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("sub_workflow_host", "start") },
|
||||
// Inner agent within sub-workflow produces text (unwrapped by WorkflowSession)
|
||||
new AgentResponseUpdate { MessageId = "msg_sub_1", Contents = [new MeaiTextContent("Sub-workflow agent output")] },
|
||||
// Sub-workflow executor completed
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("sub_workflow_host", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// 2 workflow actions + 1 text message = 3 output items
|
||||
Assert.Equal(3, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Single(events.OfType<ResponseTextDeltaEvent>());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OutputConverter_WorkflowWithMultipleContentTypes_HandlesAllCorrectlyAsync()
|
||||
{
|
||||
// Simulate a workflow producing reasoning, text, function calls, and usage
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
{
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("planner", "start") },
|
||||
// Reasoning
|
||||
new AgentResponseUpdate { Contents = [new TextReasoningContent("Let me think about this...")] },
|
||||
// Function call (tool use)
|
||||
new AgentResponseUpdate
|
||||
{
|
||||
Contents = [new FunctionCallContent("call_search", "web_search",
|
||||
new Dictionary<string, object?> { ["query"] = "latest news" })]
|
||||
},
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("planner", null) },
|
||||
// Next executor uses tool result
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("writer", "start") },
|
||||
new AgentResponseUpdate { MessageId = "msg_w1", Contents = [new MeaiTextContent("Based on my research, ")] },
|
||||
new AgentResponseUpdate { MessageId = "msg_w1", Contents = [new MeaiTextContent("here are the findings.")] },
|
||||
new AgentResponseUpdate
|
||||
{
|
||||
Contents = [new UsageContent(new UsageDetails { InputTokenCount = 500, OutputTokenCount = 200, TotalTokenCount = 700 })]
|
||||
},
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("writer", null) },
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Workflow actions: 4 (2 invoked + 2 completed)
|
||||
// Content: 1 reasoning + 1 function call + 1 text message = 3
|
||||
// Total: 7 output items
|
||||
Assert.Equal(7, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Contains(events, e => e is ResponseFunctionCallArgumentsDoneEvent);
|
||||
Assert.Equal(2, events.OfType<ResponseTextDeltaEvent>().Count());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
|
||||
private static (AgentFrameworkResponseHandler handler, CreateResponse request, ResponseContext context)
|
||||
CreateHandlerWithAgent(AIAgent agent, string userMessage)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton(agent);
|
||||
services.AddSingleton<ILogger<AgentFrameworkResponseHandler>>(NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = CreateUserInput(userMessage);
|
||||
var mockContext = CreateMockContext();
|
||||
|
||||
return (handler, request, mockContext.Object);
|
||||
}
|
||||
|
||||
private static BinaryData CreateUserInput(string text)
|
||||
{
|
||||
return BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_in_1", status = "completed", role = "user",
|
||||
content = new[] { new { type = "input_text", text } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Mock<ResponseContext> CreateMockContext()
|
||||
{
|
||||
var mock = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mock.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
mock.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<Item>());
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static (ResponseEventStream stream, Mock<ResponseContext> mockContext) CreateTestStream()
|
||||
{
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
var request = new CreateResponse { Model = "test-model" };
|
||||
var stream = new ResponseEventStream(mockContext.Object, request);
|
||||
return (stream, mockContext);
|
||||
}
|
||||
|
||||
private static async Task<List<ResponseStreamEvent>> CollectEventsAsync(
|
||||
AgentFrameworkResponseHandler handler,
|
||||
CreateResponse request,
|
||||
ResponseContext context)
|
||||
{
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in handler.CreateAsync(request, context, CancellationToken.None))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<T> ToAsync<T>(IEnumerable<T> source)
|
||||
{
|
||||
foreach (var item in source)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ===== Test Agent Types =====
|
||||
|
||||
/// <summary>
|
||||
/// A test agent that streams a single text update.
|
||||
/// </summary>
|
||||
private sealed class StreamingTextAgent(string id, string responseText) : AIAgent
|
||||
{
|
||||
public new string Id => id;
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
yield return new AgentResponseUpdate
|
||||
{
|
||||
MessageId = $"msg_{id}",
|
||||
Contents = [new MeaiTextContent(responseText)]
|
||||
};
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A test agent that always throws an exception during streaming.
|
||||
/// </summary>
|
||||
private sealed class ThrowingStreamingAgent(string id, Exception exception) : AIAgent
|
||||
{
|
||||
public new string Id => id;
|
||||
|
||||
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw exception;
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
-29
@@ -7,33 +7,13 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETCoreApp'">
|
||||
<PackageReference Include="Azure.AI.AgentServer.Responses" />
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
|
||||
<PackageReference Include="OpenTelemetry" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Hosting tests only compile on .NET Core TFMs -->
|
||||
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
|
||||
<Compile Remove="Hosting\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETCoreApp'">
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- FoundryEval tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
|
||||
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
|
||||
<Compile Remove="FoundryEvalConverterTests.cs" />
|
||||
@@ -50,15 +30,6 @@
|
||||
<None Update="TestData\OpenAIDefaultResponse.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="TestData\ToolboxRecordResponse.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="TestData\ToolboxVersionResponse.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="TestData\ToolboxVersionWithDecorationFields.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -66,7 +66,7 @@ public sealed class RequestOptionsExtensionsTests
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert: the policy is MEAI-only; the foundry-hosting supplement is added elsewhere
|
||||
// (by the polyfill DelegatingResponsesClient → HostedAgentUserAgentPolicy).
|
||||
// (by the polyfill UserAgentResponsesClient → HostedAgentUserAgentPolicy).
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
Assert.DoesNotContain("foundry-hosting/agent-framework-dotnet", handler.LastUserAgent);
|
||||
}
|
||||
|
||||
@@ -14,9 +14,6 @@ internal static class TestDataUtil
|
||||
private static readonly string s_agentResponseJson = File.ReadAllText("TestData/AgentResponse.json");
|
||||
private static readonly string s_agentVersionResponseJson = File.ReadAllText("TestData/AgentVersionResponse.json");
|
||||
private static readonly string s_openAIDefaultResponseJson = File.ReadAllText("TestData/OpenAIDefaultResponse.json");
|
||||
private static readonly string s_toolboxRecordResponseJson = File.ReadAllText("TestData/ToolboxRecordResponse.json");
|
||||
private static readonly string s_toolboxVersionResponseJson = File.ReadAllText("TestData/ToolboxVersionResponse.json");
|
||||
private static readonly string s_toolboxVersionWithDecorationFieldsJson = File.ReadAllText("TestData/ToolboxVersionWithDecorationFields.json");
|
||||
|
||||
private const string AgentDefinitionPlaceholder = "\"agent-definition-placeholder\"";
|
||||
|
||||
@@ -165,19 +162,4 @@ internal static class TestDataUtil
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the toolbox record response JSON.
|
||||
/// </summary>
|
||||
public static string GetToolboxRecordResponseJson() => s_toolboxRecordResponseJson;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the toolbox version response JSON.
|
||||
/// </summary>
|
||||
public static string GetToolboxVersionResponseJson() => s_toolboxVersionResponseJson;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the toolbox version response JSON with decoration fields on tools.
|
||||
/// </summary>
|
||||
public static string GetToolboxVersionWithDecorationFieldsJson() => s_toolboxVersionWithDecorationFieldsJson;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user