Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dotnet/agent-framework-dotnet.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ internal static AIAgent ApplyOpenTelemetry(AIAgent agent)

/// <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>
Expand All @@ -219,7 +219,7 @@ internal static AIAgent ApplyOpenTelemetry(AIAgent agent)
/// <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>
Expand Down Expand Up @@ -261,12 +261,12 @@ internal static AIAgent TryApplyUserAgent(AIAgent agent)
}

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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -104,7 +104,7 @@ private static ClientPipeline BuildDummyPipeline()
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@

<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.Foundry.UnitTests" />
<InternalsVisibleTo Include="Microsoft.Agents.AI.Foundry.Hosting.UnitTests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
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;
Expand All @@ -19,7 +18,7 @@
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,14 @@
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;
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
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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>());
Comment thread
rogerbarreto marked this conversation as resolved.
}

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));
}
}
Original file line number Diff line number Diff line change
@@ -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
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@
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
{
Expand Down
Loading
Loading