chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
This commit is contained in:
+270
@@ -0,0 +1,270 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
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.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using OpenTelemetry;
|
||||
using OpenTelemetry.Trace;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that verify OTel spans are actually emitted and captured through the
|
||||
/// <see cref="AgentFrameworkResponseHandler"/> pipeline when
|
||||
/// <see cref="FoundryHostingExtensions.ApplyOpenTelemetry"/> wraps the resolved agent.
|
||||
/// </summary>
|
||||
public class AgentFrameworkResponseHandlerTelemetryTests
|
||||
{
|
||||
/// <summary>
|
||||
/// The ActivitySource name used by ApplyOpenTelemetry() — equals AgentHostTelemetry.ResponsesSourceName.
|
||||
/// Declared as a constant so the TracerProvider and assertions reference the same literal.
|
||||
/// </summary>
|
||||
private const string ResponsesSourceName = "Azure.AI.AgentServer.Responses";
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_DefaultAgent_EmitsInvokeAgentSpanAsync()
|
||||
{
|
||||
// Arrange
|
||||
var activities = new ConcurrentActivityList();
|
||||
using var tracerProvider = Sdk.CreateTracerProviderBuilder()
|
||||
.AddSource(ResponsesSourceName)
|
||||
.AddInMemoryExporter(activities)
|
||||
.Build();
|
||||
|
||||
var agent = new TelemetryTestAgent();
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var (request, context) = BuildRequest();
|
||||
|
||||
// Act — enumerate all events so the span completes before asserting
|
||||
await foreach (var _ in handler.CreateAsync(request, context, CancellationToken.None)) { }
|
||||
|
||||
// Assert — filter by agent name to isolate this test's span from any parallel test spans
|
||||
var mySpan = Assert.Single(activities.Snapshot().Where(a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name"))).ToList());
|
||||
Assert.Equal("invoke_agent", mySpan.GetTagItem("gen_ai.operation.name"));
|
||||
Assert.NotNull(mySpan.GetTagItem("gen_ai.agent.id"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_KeyedAgent_EmitsInvokeAgentSpanAsync()
|
||||
{
|
||||
// Arrange
|
||||
var activities = new ConcurrentActivityList();
|
||||
using var tracerProvider = Sdk.CreateTracerProviderBuilder()
|
||||
.AddSource(ResponsesSourceName)
|
||||
.AddInMemoryExporter(activities)
|
||||
.Build();
|
||||
|
||||
var agent = new TelemetryTestAgent();
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddKeyedSingleton<AIAgent>("keyed-agent", agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var (request, context) = BuildRequest(agentKey: "keyed-agent");
|
||||
|
||||
// Act
|
||||
await foreach (var _ in handler.CreateAsync(request, context, CancellationToken.None)) { }
|
||||
|
||||
// Assert — filter by agent name to isolate this test's span
|
||||
var mySpan = Assert.Single(activities.Snapshot().Where(a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name"))).ToList());
|
||||
Assert.Equal("invoke_agent", mySpan.GetTagItem("gen_ai.operation.name"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_AlreadyInstrumentedAgent_EmitsSingleSpanPerRunAsync()
|
||||
{
|
||||
// Arrange — use a unique source for the pre-wrapped agent distinct from ResponsesSourceName.
|
||||
// If ApplyOpenTelemetry double-wraps, an extra span would appear on ResponsesSourceName.
|
||||
// If it correctly skips wrapping, only the pre-wrap's unique source emits spans.
|
||||
var preWrapSource = Guid.NewGuid().ToString();
|
||||
var preWrapActivities = new ConcurrentActivityList();
|
||||
var responsesActivities = new ConcurrentActivityList();
|
||||
|
||||
using var preWrapProvider = Sdk.CreateTracerProviderBuilder()
|
||||
.AddSource(preWrapSource)
|
||||
.AddInMemoryExporter(preWrapActivities)
|
||||
.Build();
|
||||
|
||||
using var responsesProvider = Sdk.CreateTracerProviderBuilder()
|
||||
.AddSource(ResponsesSourceName)
|
||||
.AddInMemoryExporter(responsesActivities)
|
||||
.Build();
|
||||
|
||||
var innerAgent = new TelemetryTestAgent();
|
||||
var preWrapped = innerAgent.AsBuilder()
|
||||
.UseOpenTelemetry(sourceName: preWrapSource)
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton(preWrapped);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
// Act
|
||||
var (request, context) = BuildRequest();
|
||||
await foreach (var _ in handler.CreateAsync(request, context, CancellationToken.None)) { }
|
||||
|
||||
// Assert — pre-wrap source emits exactly 1 span (agent ran)
|
||||
var preWrapSnapshot = preWrapActivities.Snapshot();
|
||||
Assert.Single(preWrapSnapshot);
|
||||
Assert.Equal("invoke_agent", preWrapSnapshot[0].GetTagItem("gen_ai.operation.name"));
|
||||
|
||||
// ResponsesSourceName emits 0 spans — ApplyOpenTelemetry skipped wrapping the pre-instrumented agent
|
||||
Assert.DoesNotContain(responsesActivities.Snapshot(), a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_DefaultAgent_SpanDisplayNameContainsAgentNameAsync()
|
||||
{
|
||||
// Arrange
|
||||
var activities = new ConcurrentActivityList();
|
||||
using var tracerProvider = Sdk.CreateTracerProviderBuilder()
|
||||
.AddSource(ResponsesSourceName)
|
||||
.AddInMemoryExporter(activities)
|
||||
.Build();
|
||||
|
||||
var agent = new TelemetryTestAgent();
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var (request, context) = BuildRequest();
|
||||
|
||||
// Act
|
||||
await foreach (var _ in handler.CreateAsync(request, context, CancellationToken.None)) { }
|
||||
|
||||
// Assert — display name follows "invoke_agent {Name}({Id})" convention; filter by agent name to isolate
|
||||
var mySpan = Assert.Single(activities.Snapshot().Where(a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name"))).ToList());
|
||||
Assert.Contains("invoke_agent", mySpan.DisplayName, StringComparison.Ordinal);
|
||||
Assert.Contains(TelemetryTestAgent.AgentName, mySpan.DisplayName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static (CreateResponse request, ResponseContext context) BuildRequest(string? agentKey = null)
|
||||
{
|
||||
var request = agentKey is null
|
||||
? new CreateResponse { Model = "test" }
|
||||
: new CreateResponse { Model = "test", AgentReference = new AgentReference(agentKey) };
|
||||
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
content = new[] { new { type = "input_text", text = "Hello" } } }
|
||||
});
|
||||
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([]);
|
||||
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([]);
|
||||
|
||||
return (request, mockContext.Object);
|
||||
}
|
||||
|
||||
private sealed class TelemetryTestAgent : AIAgent
|
||||
{
|
||||
public const string AgentName = "TelemetryTestAgent";
|
||||
|
||||
public override string? Name => AgentName;
|
||||
|
||||
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
SingleUpdateAsync(new AgentResponseUpdate
|
||||
{
|
||||
MessageId = "resp_msg_1",
|
||||
Contents = [new MeaiTextContent("telemetry test response")]
|
||||
}, cancellationToken);
|
||||
|
||||
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) =>
|
||||
new(new TelemetryAgentSession());
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
new(JsonDocument.Parse("{}").RootElement);
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
new(new TelemetryAgentSession());
|
||||
|
||||
private static async IAsyncEnumerable<AgentResponseUpdate> SingleUpdateAsync(
|
||||
AgentResponseUpdate update,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Yield();
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TelemetryAgentSession : AgentSession;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe <see cref="ICollection{Activity}"/> used by OTel's InMemoryExporter to capture
|
||||
/// activities emitted on globally-listened sources. Required because the exporter writes into
|
||||
/// the supplied collection from background Activity completion callbacks while the test thread
|
||||
/// may be enumerating it for assertions, and other tests in the same assembly may emit on the
|
||||
/// same source concurrently. A plain <see cref="List{Activity}"/> trips
|
||||
/// "Collection was modified; enumeration operation may not execute." in that scenario.
|
||||
/// </summary>
|
||||
private sealed class ConcurrentActivityList : ICollection<Activity>
|
||||
{
|
||||
private readonly List<Activity> _items = new();
|
||||
private readonly object _gate = new();
|
||||
|
||||
public int Count { get { lock (this._gate) { return this._items.Count; } } }
|
||||
public bool IsReadOnly => false;
|
||||
|
||||
public void Add(Activity item) { lock (this._gate) { this._items.Add(item); } }
|
||||
public void Clear() { lock (this._gate) { this._items.Clear(); } }
|
||||
public bool Contains(Activity item) { lock (this._gate) { return this._items.Contains(item); } }
|
||||
public void CopyTo(Activity[] array, int arrayIndex) { lock (this._gate) { this._items.CopyTo(array, arrayIndex); } }
|
||||
public bool Remove(Activity item) { lock (this._gate) { return this._items.Remove(item); } }
|
||||
|
||||
public Activity[] Snapshot()
|
||||
{
|
||||
lock (this._gate) { return this._items.ToArray(); }
|
||||
}
|
||||
|
||||
public IEnumerator<Activity> GetEnumerator() => ((IEnumerable<Activity>)this.Snapshot()).GetEnumerator();
|
||||
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => this.GetEnumerator();
|
||||
}
|
||||
}
|
||||
+1281
File diff suppressed because it is too large
Load Diff
+214
@@ -0,0 +1,214 @@
|
||||
// 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);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
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);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
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));
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Test fake that returns a non-null <see cref="HostedSessionContext"/> by default, allowing tests
|
||||
/// that were written before the strict isolation-key contract to keep passing without each test
|
||||
/// having to stub <c>ResponseContext.PlatformContext</c>. The constructor also accepts <see langword="null"/>
|
||||
/// so individual tests can exercise the handler's null-key error path.
|
||||
/// </summary>
|
||||
internal sealed class FakeHostedSessionIsolationKeyProvider : HostedSessionIsolationKeyProvider
|
||||
{
|
||||
public const string DefaultUserId = "test-user-isolation";
|
||||
|
||||
private readonly HostedSessionContext? _context;
|
||||
|
||||
public FakeHostedSessionIsolationKeyProvider(string? userId = DefaultUserId)
|
||||
{
|
||||
this._context = userId is null
|
||||
? null
|
||||
: new HostedSessionContext(userId);
|
||||
}
|
||||
|
||||
public override ValueTask<HostedSessionContext?> GetKeysAsync(
|
||||
ResponseContext context,
|
||||
CreateResponse request,
|
||||
CancellationToken cancellationToken)
|
||||
=> new(this._context);
|
||||
}
|
||||
+506
@@ -0,0 +1,506 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
|
||||
public sealed class FileSystemAgentSessionStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _root;
|
||||
|
||||
public FileSystemAgentSessionStoreTests()
|
||||
{
|
||||
this._root = Path.Combine(Path.GetTempPath(), "fs-session-store-tests-" + Guid.NewGuid().ToString("N"));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(this._root))
|
||||
{
|
||||
Directory.Delete(this._root, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ResolvesRootDirectoryToFullPath()
|
||||
{
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
Assert.Equal(Path.GetFullPath(this._root), store.RootDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullOrWhitespaceRoot_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new FileSystemAgentSessionStore(null!));
|
||||
Assert.Throws<ArgumentException>(() => new FileSystemAgentSessionStore(""));
|
||||
Assert.Throws<ArgumentException>(() => new FileSystemAgentSessionStore(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSessionAsync_NoFileOnDisk_ReturnsFreshSessionFromAgentAsync()
|
||||
{
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
var agent = new TestAgent();
|
||||
|
||||
var session = await store.GetSessionAsync(agent, "conv-1", userId: null);
|
||||
|
||||
Assert.NotNull(session);
|
||||
Assert.Equal(1, agent.CreateCalls);
|
||||
Assert.Equal(0, agent.DeserializeCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSessionAsync_EmptyFileOnDisk_ReturnsFreshSessionAsync()
|
||||
{
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
Directory.CreateDirectory(store.RootDirectory);
|
||||
File.WriteAllText(Path.Combine(store.RootDirectory, "conv-empty.json"), string.Empty);
|
||||
|
||||
var agent = new TestAgent();
|
||||
var session = await store.GetSessionAsync(agent, "conv-empty", userId: null);
|
||||
|
||||
Assert.NotNull(session);
|
||||
Assert.Equal(1, agent.CreateCalls);
|
||||
Assert.Equal(0, agent.DeserializeCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveSessionAsync_CreatesRootDirectoryIfMissingAsync()
|
||||
{
|
||||
var nested = Path.Combine(this._root, "nested", "deeper");
|
||||
var store = new FileSystemAgentSessionStore(nested);
|
||||
Assert.False(Directory.Exists(nested));
|
||||
|
||||
var agent = new TestAgent("{\"workflow\":\"x\"}");
|
||||
await store.SaveSessionAsync(agent, "conv-2", NewSession(), userId: null);
|
||||
|
||||
Assert.True(Directory.Exists(nested));
|
||||
Assert.True(File.Exists(Path.Combine(nested, "c-conv-2.json")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveSessionAsync_ThenGetSessionAsync_RoundTripsViaAgentSerializerAsync()
|
||||
{
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
var agent = new TestAgent("{\"foo\":42}");
|
||||
|
||||
await store.SaveSessionAsync(agent, "round-trip", NewSession(), userId: null);
|
||||
await store.GetSessionAsync(agent, "round-trip", userId: null);
|
||||
|
||||
Assert.Equal(1, agent.SerializeCalls);
|
||||
Assert.Equal(1, agent.DeserializeCalls);
|
||||
Assert.NotNull(agent.LastDeserialized);
|
||||
Assert.Equal(JsonValueKind.Object, agent.LastDeserialized!.Value.ValueKind);
|
||||
Assert.Equal(42, agent.LastDeserialized!.Value.GetProperty("foo").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveSessionAsync_TwoAgentsSameConversationId_DoNotCollideAsync()
|
||||
{
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
var agentA = new TestAgent("{\"who\":\"a\"}", name: "AgentA");
|
||||
var agentB = new TestAgent("{\"who\":\"b\"}", name: "AgentB");
|
||||
|
||||
await store.SaveSessionAsync(agentA, "shared-conv", NewSession(), userId: null);
|
||||
await store.SaveSessionAsync(agentB, "shared-conv", NewSession(), userId: null);
|
||||
|
||||
// Agents with distinct Names get distinct subdirectories so neither overwrites the other.
|
||||
var pathA = Path.Combine(store.RootDirectory, "a-AgentA", "c-shared-conv.json");
|
||||
var pathB = Path.Combine(store.RootDirectory, "a-AgentB", "c-shared-conv.json");
|
||||
Assert.True(File.Exists(pathA));
|
||||
Assert.True(File.Exists(pathB));
|
||||
Assert.Contains("\"a\"", File.ReadAllText(pathA), StringComparison.Ordinal);
|
||||
Assert.Contains("\"b\"", File.ReadAllText(pathB), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveSessionAsync_LongConversationId_DoesNotStackOverflowAsync()
|
||||
{
|
||||
// Keep the value < typical OS file-name limits (~255 chars) so the file write
|
||||
// succeeds, but long enough to force Sanitize past its small-input fast path.
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
var conversationId = new string('a', 200);
|
||||
var agent = new TestAgent();
|
||||
|
||||
await store.SaveSessionAsync(agent, conversationId, NewSession(), userId: null);
|
||||
|
||||
var files = Directory.GetFiles(store.RootDirectory, "*.json");
|
||||
Assert.Single(files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveSessionAsync_SanitizesInvalidPathCharactersAsync()
|
||||
{
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
var agent = new TestAgent();
|
||||
|
||||
// Pick an invalid filename char for the current OS. The set differs by platform
|
||||
// (e.g. '?' is invalid on Windows but not on Linux), so we must select dynamically.
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
Assert.NotEmpty(invalidChars);
|
||||
char invalid = invalidChars[0];
|
||||
// Avoid NUL specifically because some shells/loggers handle it oddly; prefer
|
||||
// the next character if available.
|
||||
if (invalid == '\0' && invalidChars.Length > 1)
|
||||
{
|
||||
invalid = invalidChars[1];
|
||||
}
|
||||
|
||||
var conversationId = $"id-with{invalid}invalid-chars";
|
||||
|
||||
await store.SaveSessionAsync(agent, conversationId, NewSession(), userId: null);
|
||||
|
||||
var files = Directory.GetFiles(store.RootDirectory, "*.json");
|
||||
Assert.Single(files);
|
||||
var fileName = Path.GetFileName(files[0]);
|
||||
Assert.DoesNotContain(invalid.ToString(), fileName, StringComparison.Ordinal);
|
||||
Assert.Contains("id-with", fileName, StringComparison.Ordinal);
|
||||
Assert.Contains("invalid-chars", fileName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveSessionAsync_ConcurrentSavesOnSameConversation_DoNotCollideOnTempFileAsync()
|
||||
{
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
var agent = new TestAgent("{\"x\":1}");
|
||||
|
||||
// Fan out N concurrent saves; with a fixed temp filename ("path.tmp") this would
|
||||
// race on FileMode.Create / Move. Verify they all complete successfully.
|
||||
var tasks = new List<Task>();
|
||||
for (int i = 0; i < 16; i++)
|
||||
{
|
||||
tasks.Add(store.SaveSessionAsync(agent, "concurrent", NewSession(), userId: null).AsTask());
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
Assert.True(File.Exists(Path.Combine(store.RootDirectory, "c-concurrent.json")));
|
||||
var leftoverTempFiles = Directory.GetFiles(store.RootDirectory, "*.tmp");
|
||||
Assert.Empty(leftoverTempFiles);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(".")]
|
||||
[InlineData("..")]
|
||||
[InlineData("...")]
|
||||
public async Task SaveSessionAsync_AgentNameIsDotSegment_DoesNotEscapeRootAsync(string agentName)
|
||||
{
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
var agent = new TestAgent(name: agentName);
|
||||
|
||||
await store.SaveSessionAsync(agent, "conv-dots", NewSession(), userId: null);
|
||||
|
||||
// The session file must land inside RootDirectory, not in (or above) it as a sibling.
|
||||
var allFiles = Directory.GetFiles(store.RootDirectory, "*.json", SearchOption.AllDirectories);
|
||||
Assert.Single(allFiles);
|
||||
var fullPath = Path.GetFullPath(allFiles[0]);
|
||||
Assert.StartsWith(Path.GetFullPath(this._root) + Path.DirectorySeparatorChar, fullPath, StringComparison.Ordinal);
|
||||
|
||||
// The bucket directory name must not be a navigable dot-segment. After
|
||||
// percent-encoding every dot in an all-dot segment, names like ".", "..", and
|
||||
// "..." become "%2E", "%2E%2E", "%2E%2E%2E" — distinct, OS-neutral filenames.
|
||||
var bucketName = Path.GetFileName(Path.GetDirectoryName(fullPath)!);
|
||||
Assert.NotEmpty(bucketName);
|
||||
Assert.NotEqual(".", bucketName);
|
||||
Assert.NotEqual("..", bucketName);
|
||||
Assert.DoesNotContain(bucketName, c => c == '.');
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveSessionAsync_DistinctNamesWithInvalidChars_ProduceDistinctFilesAsync()
|
||||
{
|
||||
// Percent-encoding must keep otherwise-colliding inputs distinct: under the
|
||||
// earlier underscore-substitution scheme, "foo/bar" and "foo_bar" both sanitized
|
||||
// to "foo_bar" and would have shared a session bucket on disk.
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
var agentSlash = new TestAgent(name: "foo/bar");
|
||||
var agentUnderscore = new TestAgent(name: "foo_bar");
|
||||
|
||||
await store.SaveSessionAsync(agentSlash, "conv-1", NewSession(), userId: null);
|
||||
await store.SaveSessionAsync(agentUnderscore, "conv-1", NewSession(), userId: null);
|
||||
|
||||
var bucketDirs = Directory.GetDirectories(store.RootDirectory);
|
||||
Assert.Equal(2, bucketDirs.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSessionAsync_NoExistingFile_DoesNotCreateAgentDirectoryAsync()
|
||||
{
|
||||
// Read operations must not have side effects on the file system.
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
var agent = new TestAgent(name: "agent-with-bucket");
|
||||
|
||||
var session = await store.GetSessionAsync(agent, "missing-id", userId: null);
|
||||
|
||||
Assert.NotNull(session);
|
||||
Assert.False(Directory.Exists(this._root), "Read miss must not create the root directory.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveDefaultRootDirectory_Hosted_RootsUnderHome()
|
||||
{
|
||||
// Arrange / Act
|
||||
var root = FileSystemAgentSessionStore.ResolveDefaultRootDirectory(
|
||||
isHosted: true,
|
||||
homeDirectory: "/home/session",
|
||||
currentDirectory: "/some/cwd");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(
|
||||
Path.Combine("/home/session", FileSystemAgentSessionStore.LocalCheckpointDirectoryName),
|
||||
root);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void ResolveDefaultRootDirectory_HostedWithoutHome_UsesDefaultSessionDataDirectory(string? home)
|
||||
{
|
||||
// Arrange / Act
|
||||
var root = FileSystemAgentSessionStore.ResolveDefaultRootDirectory(
|
||||
isHosted: true,
|
||||
homeDirectory: home,
|
||||
currentDirectory: "/some/cwd");
|
||||
|
||||
// Assert: falls back to the spec default ("/home/session"), never the filesystem root.
|
||||
Assert.Equal(
|
||||
Path.Combine(
|
||||
FileSystemAgentSessionStore.DefaultHostedSessionDataDirectory,
|
||||
FileSystemAgentSessionStore.LocalCheckpointDirectoryName),
|
||||
root);
|
||||
Assert.NotEqual("/.checkpoints", root);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveDefaultRootDirectory_NotHosted_UsesCurrentDirectory()
|
||||
{
|
||||
// Arrange / Act
|
||||
var root = FileSystemAgentSessionStore.ResolveDefaultRootDirectory(
|
||||
isHosted: false,
|
||||
homeDirectory: "/home/session",
|
||||
currentDirectory: "/some/cwd");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(
|
||||
Path.Combine("/some/cwd", FileSystemAgentSessionStore.LocalCheckpointDirectoryName),
|
||||
root);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/")]
|
||||
public void ResolveDefaultRootDirectory_HostedWithFilesystemRootHome_FallsBackToDefault(string home)
|
||||
{
|
||||
// Arrange / Act: a filesystem-root HOME (e.g. "/") must NOT root the store at
|
||||
// "/.checkpoints", which is read-only in the container and caused issue #6231.
|
||||
var root = FileSystemAgentSessionStore.ResolveDefaultRootDirectory(
|
||||
isHosted: true,
|
||||
homeDirectory: home,
|
||||
currentDirectory: "/some/cwd");
|
||||
|
||||
// Assert: falls back to the default session-data directory, never the filesystem root.
|
||||
Assert.Equal(
|
||||
Path.Combine(
|
||||
FileSystemAgentSessionStore.DefaultHostedSessionDataDirectory,
|
||||
FileSystemAgentSessionStore.LocalCheckpointDirectoryName),
|
||||
root);
|
||||
Assert.NotEqual(
|
||||
Path.Combine(Path.GetPathRoot(Path.GetFullPath(home))!, FileSystemAgentSessionStore.LocalCheckpointDirectoryName),
|
||||
root);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveSessionAsync_NonWritableDirectory_ThrowsClearActionableIOExceptionAsync()
|
||||
{
|
||||
// Arrange: place a file where the store's root directory needs to be created. Creating
|
||||
// a directory under an existing file fails with IOException on every OS, standing in for
|
||||
// the read-only root filesystem of a Foundry hosted container (issue #6231).
|
||||
Directory.CreateDirectory(this._root);
|
||||
var blockingFile = Path.Combine(this._root, "blocking-file");
|
||||
File.WriteAllText(blockingFile, "x");
|
||||
|
||||
var store = new FileSystemAgentSessionStore(Path.Combine(blockingFile, ".checkpoints"));
|
||||
var agent = new TestAgent();
|
||||
|
||||
// Act
|
||||
var ex = await Assert.ThrowsAsync<IOException>(
|
||||
async () => await store.SaveSessionAsync(agent, "conv-fatal", NewSession(), userId: null));
|
||||
|
||||
// Assert: failure stays fatal but the message is clear and actionable, and the original
|
||||
// IO error is preserved as the inner exception.
|
||||
Assert.Contains("could not be created or written to", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains(FileSystemAgentSessionStore.SessionDataDirectoryEnvironmentVariable, ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains(FileSystemAgentSessionStore.DefaultHostedSessionDataDirectory, ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains(store.RootDirectory, ex.Message, StringComparison.Ordinal);
|
||||
Assert.NotNull(ex.InnerException);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveSessionAsync_WithUserId_NestsUnderPrefixedAgentAndUserAsync()
|
||||
{
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
var agent = new TestAgent("{\"who\":\"alice\"}", name: "Concierge");
|
||||
|
||||
await store.SaveSessionAsync(agent, "conv-1", NewSession(), userId: "alice");
|
||||
|
||||
// Layout: {root}/a-{agent}/u-{userId}/c-{conv}.json
|
||||
var expected = Path.Combine(store.RootDirectory, "a-Concierge", "u-alice", "c-conv-1.json");
|
||||
Assert.True(File.Exists(expected), $"expected session at {expected}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveSessionAsync_NoUserId_OmitsUserSegmentAsync()
|
||||
{
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
var agent = new TestAgent(name: "Concierge");
|
||||
|
||||
await store.SaveSessionAsync(agent, "conv-1", NewSession(), userId: null);
|
||||
|
||||
// No user id -> the u- layer collapses: {root}/a-{agent}/c-{conv}.json
|
||||
var expected = Path.Combine(store.RootDirectory, "a-Concierge", "c-conv-1.json");
|
||||
Assert.True(File.Exists(expected), $"expected session at {expected}");
|
||||
Assert.Empty(Directory.GetDirectories(Path.Combine(store.RootDirectory, "a-Concierge")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSessionAsync_DifferentUser_DoesNotReadAnotherUsersSessionAsync()
|
||||
{
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
var agent = new TestAgent("{\"secret\":\"alice-only\"}", name: "Concierge");
|
||||
|
||||
// Alice saves under the same conversationId Bob will guess/forge.
|
||||
await store.SaveSessionAsync(agent, "shared-conv", NewSession(), userId: "alice");
|
||||
|
||||
// Bob requests the same conversationId. The per-user partition means Bob's path is distinct,
|
||||
// so the store returns a fresh session (no leak), not Alice's persisted state.
|
||||
var bobSession = await store.GetSessionAsync(agent, "shared-conv", userId: "bob");
|
||||
|
||||
Assert.NotNull(bobSession);
|
||||
Assert.Equal(1, agent.CreateCalls); // fresh session created for Bob
|
||||
Assert.Equal(0, agent.DeserializeCalls); // Alice's file never deserialized for Bob
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveSessionAsync_UserIdEqualToAgentName_StaysDistinctViaPrefixesAsync()
|
||||
{
|
||||
// Without prefixes, agent "x" + no user could collide with no-agent + user "x". The a-/u-
|
||||
// prefixes keep the layers unambiguous.
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
var agent = new TestAgent(name: "x");
|
||||
|
||||
await store.SaveSessionAsync(agent, "conv-1", NewSession(), userId: "x");
|
||||
|
||||
var expected = Path.Combine(store.RootDirectory, "a-x", "u-x", "c-conv-1.json");
|
||||
Assert.True(File.Exists(expected), $"expected session at {expected}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("../../escape")]
|
||||
[InlineData("..")]
|
||||
[InlineData("user/../../escape")]
|
||||
[InlineData("a/b")]
|
||||
[InlineData("a\\b")]
|
||||
[InlineData(".")]
|
||||
public async Task SaveSessionAsync_TraversalUserId_IsRejectedAsync(string userId)
|
||||
{
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
var agent = new TestAgent(name: "Concierge");
|
||||
|
||||
// A forged user id that is not a single safe path segment is rejected outright (CWE-22),
|
||||
// not sanitized — so it can never escape the storage root.
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await store.SaveSessionAsync(agent, "conv-1", NewSession(), userId: userId));
|
||||
|
||||
// Nothing was written outside (or inside) the root.
|
||||
Assert.False(Directory.Exists(this._root) && Directory.GetFiles(this._root, "*.json", SearchOption.AllDirectories).Length > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveSessionAsync_AbsoluteOrRootedUserId_IsRejectedAsync()
|
||||
{
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
var agent = new TestAgent(name: "Concierge");
|
||||
|
||||
var rooted = Path.IsPathRooted("/etc") ? "/etc" : Path.GetFullPath("/etc");
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await store.SaveSessionAsync(agent, "conv-1", NewSession(), userId: rooted));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveSessionAsync_ThenGetSessionAsync_WithUserId_RoundTripsAsync()
|
||||
{
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
var agent = new TestAgent("{\"foo\":7}", name: "Concierge");
|
||||
|
||||
await store.SaveSessionAsync(agent, "round-trip", NewSession(), userId: "alice");
|
||||
await store.GetSessionAsync(agent, "round-trip", userId: "alice");
|
||||
|
||||
Assert.Equal(1, agent.SerializeCalls);
|
||||
Assert.Equal(1, agent.DeserializeCalls);
|
||||
Assert.Equal(7, agent.LastDeserialized!.Value.GetProperty("foo").GetInt32());
|
||||
}
|
||||
|
||||
private static TestSession NewSession() => new();
|
||||
|
||||
private sealed class TestSession : AgentSession
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class TestAgent : AIAgent
|
||||
{
|
||||
private readonly string _serializedJson;
|
||||
private readonly string? _name;
|
||||
|
||||
public TestAgent(string serializedJson = "{}", string? name = null)
|
||||
{
|
||||
this._serializedJson = serializedJson;
|
||||
this._name = name;
|
||||
}
|
||||
|
||||
public override string? Name => this._name;
|
||||
|
||||
public int CreateCalls { get; private set; }
|
||||
public int SerializeCalls { get; private set; }
|
||||
public int DeserializeCalls { get; private set; }
|
||||
public JsonElement? LastDeserialized { get; private set; }
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.CreateCalls++;
|
||||
return new ValueTask<AgentSession>(NewSession());
|
||||
}
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.SerializeCalls++;
|
||||
using var doc = JsonDocument.Parse(this._serializedJson);
|
||||
return new ValueTask<JsonElement>(doc.RootElement.Clone());
|
||||
}
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.DeserializeCalls++;
|
||||
this.LastDeserialized = serializedState.Clone();
|
||||
return new ValueTask<AgentSession>(NewSession());
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<Extensions.AI.ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<Extensions.AI.ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
public class FoundryAIToolExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateHostedMcpToolbox_FromToolboxRecord_UsesNameAndDefaultVersion()
|
||||
{
|
||||
var record = Azure.AI.Projects.Agents.ProjectsAgentsModelFactory.ToolboxRecord(
|
||||
id: "tbx-123",
|
||||
name: "calendar-tools",
|
||||
defaultVersion: "v2");
|
||||
|
||||
var tool = FoundryAIToolExtensions.CreateHostedMcpToolbox(record);
|
||||
|
||||
var marker = Assert.IsType<HostedMcpToolboxAITool>(tool);
|
||||
Assert.Equal("calendar-tools", marker.ToolboxName);
|
||||
Assert.Equal("v2", marker.Version);
|
||||
Assert.Equal("foundry-toolbox://calendar-tools?version=v2", marker.ServerAddress);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateHostedMcpToolbox_FromToolboxRecord_NullDefaultVersionOmitsQuery()
|
||||
{
|
||||
var record = Azure.AI.Projects.Agents.ProjectsAgentsModelFactory.ToolboxRecord(
|
||||
id: "tbx-abc",
|
||||
name: "finance-tools",
|
||||
defaultVersion: null);
|
||||
|
||||
var tool = FoundryAIToolExtensions.CreateHostedMcpToolbox(record);
|
||||
|
||||
var marker = Assert.IsType<HostedMcpToolboxAITool>(tool);
|
||||
Assert.Equal("finance-tools", marker.ToolboxName);
|
||||
Assert.Null(marker.Version);
|
||||
Assert.Equal("foundry-toolbox://finance-tools", marker.ServerAddress);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateHostedMcpToolbox_FromToolboxRecord_Null_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => FoundryAIToolExtensions.CreateHostedMcpToolbox((Azure.AI.Projects.Agents.ToolboxRecord)null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateHostedMcpToolbox_FromToolboxVersion_UsesNameAndVersion()
|
||||
{
|
||||
var version = Azure.AI.Projects.Agents.ProjectsAgentsModelFactory.ToolboxVersion(
|
||||
metadata: null,
|
||||
id: "ver-1",
|
||||
name: "hr-tools",
|
||||
version: "2025-09-01",
|
||||
description: "HR toolbox",
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
tools: null,
|
||||
policies: null);
|
||||
|
||||
var tool = FoundryAIToolExtensions.CreateHostedMcpToolbox(version);
|
||||
|
||||
var marker = Assert.IsType<HostedMcpToolboxAITool>(tool);
|
||||
Assert.Equal("hr-tools", marker.ToolboxName);
|
||||
Assert.Equal("2025-09-01", marker.Version);
|
||||
Assert.Equal("foundry-toolbox://hr-tools?version=2025-09-01", marker.ServerAddress);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateHostedMcpToolbox_FromToolboxVersion_Null_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => FoundryAIToolExtensions.CreateHostedMcpToolbox((Azure.AI.Projects.Agents.ToolboxVersion)null!));
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// xUnit collection that serializes tests mutating the <c>FOUNDRY_PROJECT_ENDPOINT</c>
|
||||
/// process environment variable. Without this, parallel test execution causes flaky
|
||||
/// races between tests that set / unset the variable.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name, DisableParallelization = true)]
|
||||
public sealed class FoundryProjectEndpointEnvFixture
|
||||
{
|
||||
public const string Name = "FoundryProjectEndpointEnv";
|
||||
}
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Core;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
public class FoundryToolboxBearerTokenHandlerTests
|
||||
{
|
||||
private const string FakeToken = "test-bearer-token";
|
||||
|
||||
private static Mock<TokenCredential> CreateMockCredential()
|
||||
{
|
||||
var mock = new Mock<TokenCredential>();
|
||||
mock.Setup(c => c.GetTokenAsync(It.IsAny<TokenRequestContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new AccessToken(FakeToken, DateTimeOffset.UtcNow.AddHours(1)));
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static (FoundryToolboxBearerTokenHandler Handler, CountingHandler Inner) CreateHandlerPair(
|
||||
Mock<TokenCredential>? credential = null,
|
||||
string? featuresHeader = null,
|
||||
HttpStatusCode statusCode = HttpStatusCode.OK)
|
||||
{
|
||||
credential ??= CreateMockCredential();
|
||||
var inner = new CountingHandler(statusCode);
|
||||
var handler = new FoundryToolboxBearerTokenHandler(credential.Object, featuresHeader)
|
||||
{
|
||||
InnerHandler = inner
|
||||
};
|
||||
return (handler, inner);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_ForwardsCallIdWhenSetAsync()
|
||||
{
|
||||
// Arrange
|
||||
var (handler, _) = CreateHandlerPair();
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
HostedCallContext.CallId = "call-abc";
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
using var response = await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(request.Headers.TryGetValues("x-agent-foundry-call-id", out var values));
|
||||
Assert.Equal("call-abc", values!.Single());
|
||||
}
|
||||
finally
|
||||
{
|
||||
HostedCallContext.CallId = null;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_OmitsCallIdWhenAbsentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var (handler, _) = CreateHandlerPair();
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
HostedCallContext.CallId = null;
|
||||
|
||||
// Act
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
using var response = await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.False(request.Headers.Contains("x-agent-foundry-call-id"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_InjectsBearerTokenAsync()
|
||||
{
|
||||
var (handler, _) = CreateHandlerPair();
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
using var response = await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("Bearer", request.Headers.Authorization?.Scheme);
|
||||
Assert.Equal(FakeToken, request.Headers.Authorization?.Parameter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_UsesAiAzureComScopeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var capturedContexts = new List<TokenRequestContext>();
|
||||
var credential = new Mock<TokenCredential>();
|
||||
credential
|
||||
.Setup(c => c.GetTokenAsync(It.IsAny<TokenRequestContext>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<TokenRequestContext, CancellationToken>((ctx, _) => capturedContexts.Add(ctx))
|
||||
.ReturnsAsync(new AccessToken(FakeToken, DateTimeOffset.MaxValue));
|
||||
var (handler, _) = CreateHandlerPair(credential);
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
// Act
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert: spec §4 mandates the https://ai.azure.com audience.
|
||||
Assert.Single(capturedContexts);
|
||||
Assert.Contains("https://ai.azure.com/.default", capturedContexts[0].Scopes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_AlwaysInjectsMandatoryFoundryFeaturesHeaderAsync()
|
||||
{
|
||||
// Arrange
|
||||
var (handler, _) = CreateHandlerPair(featuresHeader: null);
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
// Act
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
using var response = await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert: spec §2 requires Foundry-Features: Toolboxes=V1Preview on every request.
|
||||
Assert.True(request.Headers.TryGetValues("Foundry-Features", out var values));
|
||||
Assert.Equal("Toolboxes=V1Preview", values.Single());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_MergesMandatoryAndOverrideFeaturesAsync()
|
||||
{
|
||||
var (handler, _) = CreateHandlerPair(featuresHeader: "feature1,feature2");
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.True(request.Headers.TryGetValues("Foundry-Features", out var values));
|
||||
var header = values.Single();
|
||||
Assert.Contains("Toolboxes=V1Preview", header, StringComparison.Ordinal);
|
||||
Assert.Contains("feature1", header, StringComparison.Ordinal);
|
||||
Assert.Contains("feature2", header, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_DoesNotDuplicateMandatoryFlagAsync()
|
||||
{
|
||||
// Override already contains the mandatory flag — must not be duplicated in the merged value.
|
||||
var (handler, _) = CreateHandlerPair(featuresHeader: "Toolboxes=V1Preview");
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.True(request.Headers.TryGetValues("Foundry-Features", out var values));
|
||||
var header = values.Single();
|
||||
var count = 0;
|
||||
var idx = 0;
|
||||
while ((idx = header.IndexOf("Toolboxes=V1Preview", idx, StringComparison.OrdinalIgnoreCase)) >= 0)
|
||||
{
|
||||
count++;
|
||||
idx += "Toolboxes=V1Preview".Length;
|
||||
}
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_PropagatesTraceContextFromActivityAsync()
|
||||
{
|
||||
// Arrange: activate an Activity so Activity.Current is populated.
|
||||
using var listener = new ActivityListener
|
||||
{
|
||||
ShouldListenTo = _ => true,
|
||||
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
|
||||
};
|
||||
ActivitySource.AddActivityListener(listener);
|
||||
using var source = new ActivitySource("test-source");
|
||||
using var activity = source.StartActivity("test-op")!;
|
||||
Assert.NotNull(activity);
|
||||
activity.TraceStateString = "vendor=value";
|
||||
activity.AddBaggage("user", "alice");
|
||||
|
||||
var (handler, _) = CreateHandlerPair();
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
// Act
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert: spec §6.3 requires traceparent/tracestate/baggage propagation.
|
||||
Assert.True(request.Headers.TryGetValues("traceparent", out var tpValues));
|
||||
Assert.Contains(activity.TraceId.ToString(), tpValues.Single(), StringComparison.Ordinal);
|
||||
|
||||
Assert.True(request.Headers.TryGetValues("tracestate", out var tsValues));
|
||||
Assert.Equal("vendor=value", tsValues.Single());
|
||||
|
||||
Assert.True(request.Headers.TryGetValues("baggage", out var bgValues));
|
||||
Assert.Contains("user=alice", bgValues.Single(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_DoesNotOverrideExistingTraceparentAsync()
|
||||
{
|
||||
// Caller pre-set traceparent on the message; must not be duplicated or replaced.
|
||||
using var listener = new ActivityListener
|
||||
{
|
||||
ShouldListenTo = _ => true,
|
||||
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
|
||||
};
|
||||
ActivitySource.AddActivityListener(listener);
|
||||
using var source = new ActivitySource("test-source");
|
||||
using var activity = source.StartActivity("test-op")!;
|
||||
Assert.NotNull(activity);
|
||||
|
||||
var (handler, _) = CreateHandlerPair();
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
const string PresetTraceparent = "00-00000000000000000000000000000001-0000000000000001-01";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
request.Headers.TryAddWithoutValidation("traceparent", PresetTraceparent);
|
||||
|
||||
// Act
|
||||
await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(request.Headers.TryGetValues("traceparent", out var values));
|
||||
var list = values.ToList();
|
||||
Assert.Single(list);
|
||||
Assert.Equal(PresetTraceparent, list[0]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.OK)]
|
||||
[InlineData(HttpStatusCode.Created)]
|
||||
[InlineData(HttpStatusCode.BadRequest)]
|
||||
[InlineData(HttpStatusCode.NotFound)]
|
||||
public async Task SendAsync_NonRetryableStatusCode_ReturnsImmediatelyAsync(HttpStatusCode statusCode)
|
||||
{
|
||||
var (handler, inner) = CreateHandlerPair(statusCode: statusCode);
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
using var response = await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.Equal(statusCode, response.StatusCode);
|
||||
Assert.Equal(1, inner.CallCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.TooManyRequests)]
|
||||
[InlineData(HttpStatusCode.InternalServerError)]
|
||||
[InlineData(HttpStatusCode.BadGateway)]
|
||||
[InlineData(HttpStatusCode.ServiceUnavailable)]
|
||||
public async Task SendAsync_RetryableStatusCode_RetriesMaxTimesAsync(HttpStatusCode statusCode)
|
||||
{
|
||||
var (handler, inner) = CreateHandlerPair(statusCode: statusCode);
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
using var response = await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
// MaxRetries is 3, so exactly 3 total attempts (not 4).
|
||||
Assert.Equal(3, inner.CallCount);
|
||||
Assert.Equal(statusCode, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_RetryableStatusCode_SucceedsOnSecondAttemptAsync()
|
||||
{
|
||||
// First call returns 503, second returns 200.
|
||||
var inner = new SequenceHandler(
|
||||
HttpStatusCode.ServiceUnavailable,
|
||||
HttpStatusCode.OK);
|
||||
|
||||
var handler = new FoundryToolboxBearerTokenHandler(CreateMockCredential().Object, null)
|
||||
{
|
||||
InnerHandler = inner
|
||||
};
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
using var response = await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(2, inner.CallCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A test handler that always returns the configured status code and counts how many times it was called.
|
||||
/// </summary>
|
||||
private sealed class CountingHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _statusCode;
|
||||
private int _callCount;
|
||||
|
||||
public int CallCount => this._callCount;
|
||||
|
||||
public CountingHandler(HttpStatusCode statusCode)
|
||||
{
|
||||
this._statusCode = statusCode;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Interlocked.Increment(ref this._callCount);
|
||||
return Task.FromResult(new HttpResponseMessage(this._statusCode));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A test handler that returns status codes from a sequence, cycling through them.
|
||||
/// </summary>
|
||||
private sealed class SequenceHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode[] _statusCodes;
|
||||
private int _callCount;
|
||||
|
||||
public int CallCount => this._callCount;
|
||||
|
||||
public SequenceHandler(params HttpStatusCode[] statusCodes)
|
||||
{
|
||||
this._statusCodes = statusCodes;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var index = Interlocked.Increment(ref this._callCount) - 1;
|
||||
var statusCode = index < this._statusCodes.Length
|
||||
? this._statusCodes[index]
|
||||
: this._statusCodes[^1];
|
||||
return Task.FromResult(new HttpResponseMessage(statusCode));
|
||||
}
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Core;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
[Collection(FoundryProjectEndpointEnvFixture.Name)]
|
||||
public class FoundryToolboxHealthCheckTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_PendingStatus_ReturnsConfiguredFailureAsync()
|
||||
{
|
||||
// Arrange: a fresh FoundryToolboxService whose StartAsync has never run reports
|
||||
// Pending. The health check must surface that as the registration's failure
|
||||
// status so the platform waits before sending traffic.
|
||||
var service = CreateServiceWithoutStarting();
|
||||
var check = new FoundryToolboxHealthCheck(service);
|
||||
var context = NewContext(failureStatus: HealthStatus.Unhealthy);
|
||||
|
||||
// Act
|
||||
var result = await check.CheckHealthAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthStatus.Unhealthy, result.Status);
|
||||
Assert.Contains("startup has not completed", result.Description, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_NoEndpointStatus_ReturnsHealthyAsync()
|
||||
{
|
||||
// Arrange: no FOUNDRY_PROJECT_ENDPOINT / AZURE_AI_PROJECT_ENDPOINT is normal local-dev.
|
||||
// The container must still pass readiness because the rest of the agent is functional.
|
||||
var savedFoundry = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT");
|
||||
var savedAzure = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT");
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", null);
|
||||
Environment.SetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT", null);
|
||||
try
|
||||
{
|
||||
var service = CreateServiceWithoutStarting(toolbox: "any");
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
var check = new FoundryToolboxHealthCheck(service);
|
||||
var context = NewContext(failureStatus: HealthStatus.Unhealthy);
|
||||
|
||||
// Act
|
||||
var result = await check.CheckHealthAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthStatus.Healthy, result.Status);
|
||||
Assert.Equal(FoundryToolboxStartupStatus.NoEndpoint, service.StartupStatus);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", savedFoundry);
|
||||
Environment.SetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT", savedAzure);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_DegradedStatus_StaysRoutableForDeferredToolboxAsync()
|
||||
{
|
||||
// Arrange: a pre-registered toolbox at an unreachable endpoint forces StartAsync to defer
|
||||
// the toolbox (non-consent failure). The container must stay routable so the toolbox can be
|
||||
// retried per-request, where the platform injects the per-user isolation key on egress.
|
||||
var options = new FoundryToolboxOptions
|
||||
{
|
||||
EndpointOverride = "http://127.0.0.1:1/unreachable",
|
||||
};
|
||||
options.ToolboxNames.Add("broken-toolbox");
|
||||
var service = new FoundryToolboxService(Options.Create(options), Mock.Of<TokenCredential>());
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
var check = new FoundryToolboxHealthCheck(service);
|
||||
var context = NewContext(failureStatus: HealthStatus.Unhealthy);
|
||||
|
||||
// Act
|
||||
var result = await check.CheckHealthAsync(context);
|
||||
|
||||
// Assert: deferred toolbox keeps the container Healthy (routable), not bricked.
|
||||
Assert.Equal(FoundryToolboxStartupStatus.Degraded, service.StartupStatus);
|
||||
Assert.Equal(HealthStatus.Healthy, result.Status);
|
||||
Assert.Equal("broken-toolbox", Assert.Single(service.DeferredToolboxNames));
|
||||
Assert.Contains("deferred", result.Description, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_HealthyStatus_ReturnsHealthyAsync()
|
||||
{
|
||||
// Arrange: an endpoint set but no pre-registered toolboxes is the legitimate
|
||||
// lazy-only setup. StartAsync reports Healthy and the check must agree.
|
||||
var options = new FoundryToolboxOptions
|
||||
{
|
||||
EndpointOverride = "http://127.0.0.1:1/unused",
|
||||
};
|
||||
var service = new FoundryToolboxService(Options.Create(options), Mock.Of<TokenCredential>());
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
var check = new FoundryToolboxHealthCheck(service);
|
||||
var context = NewContext(failureStatus: HealthStatus.Unhealthy);
|
||||
|
||||
// Act
|
||||
var result = await check.CheckHealthAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthStatus.Healthy, result.Status);
|
||||
}
|
||||
|
||||
private static FoundryToolboxService CreateServiceWithoutStarting(string? toolbox = null)
|
||||
{
|
||||
var options = new FoundryToolboxOptions();
|
||||
if (toolbox is not null)
|
||||
{
|
||||
options.ToolboxNames.Add(toolbox);
|
||||
}
|
||||
return new FoundryToolboxService(Options.Create(options), Mock.Of<TokenCredential>());
|
||||
}
|
||||
|
||||
private static HealthCheckContext NewContext(HealthStatus failureStatus) =>
|
||||
new()
|
||||
{
|
||||
Registration = new HealthCheckRegistration(
|
||||
name: "foundry-toolbox",
|
||||
instance: Mock.Of<IHealthCheck>(),
|
||||
failureStatus: failureStatus,
|
||||
tags: null),
|
||||
};
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Core;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that resolving a per-request toolbox marker via
|
||||
/// <see cref="FoundryToolboxService.GetToolboxToolsAsync"/> is strictly request-scoped: a marker
|
||||
/// referenced by one request must never leak its tools into — or raise a consent prompt on — a
|
||||
/// request that did not reference it, and must not flip the container-global startup state.
|
||||
/// </summary>
|
||||
[Collection(FoundryProjectEndpointEnvFixture.Name)]
|
||||
public class FoundryToolboxMarkerScopingTests
|
||||
{
|
||||
private static async Task<FoundryToolboxService> CreateStartedServiceAsync(
|
||||
Func<string, string?, CancellationToken, Task<FoundryToolboxService.ToolboxOpenResult>> opener)
|
||||
{
|
||||
// Non-strict, no pre-registered toolboxes: StartAsync only resolves the endpoint (so the
|
||||
// marker path can run) and reports Healthy. The opener seam replaces the network I/O.
|
||||
var options = new FoundryToolboxOptions
|
||||
{
|
||||
StrictMode = false,
|
||||
EndpointOverride = "http://127.0.0.1:1/unused",
|
||||
};
|
||||
|
||||
var service = new FoundryToolboxService(
|
||||
Options.Create(options),
|
||||
Mock.Of<TokenCredential>())
|
||||
{
|
||||
ToolboxOpener = opener,
|
||||
};
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
// Sanity: a clean start with no pre-registered toolboxes is Healthy and global-empty.
|
||||
Assert.Equal(FoundryToolboxStartupStatus.Healthy, service.StartupStatus);
|
||||
Assert.Empty(service.ConsentRequiredToolboxNames);
|
||||
Assert.Empty(service.Tools);
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolboxToolsAsync_MarkerConsent_IsRequestScopedAndDoesNotMutateGlobalStateAsync()
|
||||
{
|
||||
// Arrange: the opener reports CONSENT_REQUIRED for every marker.
|
||||
await using var service = await CreateStartedServiceAsync(
|
||||
(name, _, _) => Task.FromResult(
|
||||
new FoundryToolboxService.ToolboxOpenResult(
|
||||
Cached: null,
|
||||
Consents: [new McpConsentInfo(name, $"{name}.tool", $"https://consent.example/{name}")])));
|
||||
|
||||
// Act: request A references marker-a and hits consent.
|
||||
var resolutionA = await service.GetToolboxToolsAsync("marker-a", version: null, CancellationToken.None);
|
||||
|
||||
// Assert: the consent is returned to THIS caller, with no tools.
|
||||
Assert.Empty(resolutionA.Tools);
|
||||
Assert.Single(resolutionA.Consents);
|
||||
Assert.Equal("https://consent.example/marker-a", resolutionA.Consents[0].ConsentUrl);
|
||||
|
||||
// Assert: container-global state is unchanged. The marker consent did not get recorded in
|
||||
// ConsentRequiredToolboxNames and did not flip StartupStatus to ConsentRequired, so a request
|
||||
// with no marker (which reads StartupStatus / Tools) is unaffected.
|
||||
Assert.Equal(FoundryToolboxStartupStatus.Healthy, service.StartupStatus);
|
||||
Assert.Empty(service.ConsentRequiredToolboxNames);
|
||||
Assert.Empty(service.Tools);
|
||||
|
||||
// Act: a different request references marker-b. Its consent must not accumulate globally.
|
||||
var resolutionB = await service.GetToolboxToolsAsync("marker-b", version: null, CancellationToken.None);
|
||||
|
||||
// Assert: still request-scoped, still no global mutation.
|
||||
Assert.Single(resolutionB.Consents);
|
||||
Assert.Equal("https://consent.example/marker-b", resolutionB.Consents[0].ConsentUrl);
|
||||
Assert.Equal(FoundryToolboxStartupStatus.Healthy, service.StartupStatus);
|
||||
Assert.Empty(service.ConsentRequiredToolboxNames);
|
||||
Assert.Empty(service.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolboxToolsAsync_MarkerTools_AreReturnedToCallerNotInjectedGloballyAsync()
|
||||
{
|
||||
// Arrange: the opener resolves marker-a to one tool; any other marker stays consent-gated.
|
||||
AITool markerTool = AIFunctionFactory.Create(() => "ok", name: "marker_a_tool");
|
||||
|
||||
await using var service = await CreateStartedServiceAsync(
|
||||
(name, _, _) => Task.FromResult(
|
||||
string.Equals(name, "marker-a", StringComparison.OrdinalIgnoreCase)
|
||||
? new FoundryToolboxService.ToolboxOpenResult(
|
||||
new FoundryToolboxService.CachedToolbox(Client: null, new HttpClient(), [markerTool]),
|
||||
Consents: null)
|
||||
: new FoundryToolboxService.ToolboxOpenResult(
|
||||
Cached: null,
|
||||
Consents: [new McpConsentInfo(name, $"{name}.tool", $"https://consent.example/{name}")])));
|
||||
|
||||
// Act: request A references marker-a and resolves its tool.
|
||||
var resolutionA = await service.GetToolboxToolsAsync("marker-a", version: null, CancellationToken.None);
|
||||
|
||||
// Assert: the tool is returned to THIS caller only.
|
||||
Assert.Empty(resolutionA.Consents);
|
||||
Assert.Single(resolutionA.Tools);
|
||||
Assert.Same(markerTool, resolutionA.Tools[0]);
|
||||
|
||||
// Assert: the resolved marker tool was NOT merged into the service-wide Tools cache, so a
|
||||
// later request with no marker (which only ever gets _toolboxService.Tools) sees nothing.
|
||||
Assert.Empty(service.Tools);
|
||||
|
||||
// Act: re-resolving marker-a returns the cached tool (no second open), still request-scoped.
|
||||
var resolutionAgain = await service.GetToolboxToolsAsync("marker-a", version: null, CancellationToken.None);
|
||||
|
||||
// Assert.
|
||||
Assert.Single(resolutionAgain.Tools);
|
||||
Assert.Same(markerTool, resolutionAgain.Tools[0]);
|
||||
Assert.Empty(service.Tools);
|
||||
}
|
||||
}
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Core;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
[Collection(FoundryProjectEndpointEnvFixture.Name)]
|
||||
public class FoundryToolboxServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetToolboxToolsAsync_StrictMode_ThrowsForUnknownToolboxAsync()
|
||||
{
|
||||
var options = new FoundryToolboxOptions { StrictMode = true };
|
||||
var service = new FoundryToolboxService(
|
||||
Options.Create(options),
|
||||
Mock.Of<TokenCredential>());
|
||||
|
||||
// Act + Assert: no StartAsync so Tools is empty; unknown name in strict mode throws.
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await service.GetToolboxToolsAsync("missing", version: null, CancellationToken.None));
|
||||
|
||||
Assert.Contains("missing", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("StrictMode", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolboxToolsAsync_NonStrictMode_RequiresEndpointAsync()
|
||||
{
|
||||
var options = new FoundryToolboxOptions { StrictMode = false };
|
||||
var service = new FoundryToolboxService(
|
||||
Options.Create(options),
|
||||
Mock.Of<TokenCredential>());
|
||||
|
||||
// Without calling StartAsync, endpoint is not resolved so lazy-open fails clearly.
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await service.GetToolboxToolsAsync("missing", version: null, CancellationToken.None));
|
||||
|
||||
Assert.Contains("FOUNDRY_PROJECT_ENDPOINT", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WithoutEndpoint_LeavesToolsEmptyAsync()
|
||||
{
|
||||
// Ensure neither env var is set (tests may run in any CI environment)
|
||||
var savedFoundry = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT");
|
||||
var savedAzure = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT");
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", null);
|
||||
Environment.SetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT", null);
|
||||
try
|
||||
{
|
||||
var options = new FoundryToolboxOptions();
|
||||
options.ToolboxNames.Add("any");
|
||||
var service = new FoundryToolboxService(
|
||||
Options.Create(options),
|
||||
Mock.Of<TokenCredential>());
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.Empty(service.Tools);
|
||||
Assert.Equal(FoundryToolboxStartupStatus.NoEndpoint, service.StartupStatus);
|
||||
Assert.Empty(service.FailedToolboxNames);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", savedFoundry);
|
||||
Environment.SetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT", savedAzure);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_AttemptsOpenForPreRegisteredToolboxFromProjectEndpointAsync()
|
||||
{
|
||||
// Arrange: point the service at an unreachable host and confirm StartAsync
|
||||
// attempts to open the pre-registered toolbox (verified via DeferredToolboxNames
|
||||
// recording the attempted name and StartupStatus reflecting the deferral).
|
||||
var saved = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT");
|
||||
Environment.SetEnvironmentVariable(
|
||||
"FOUNDRY_PROJECT_ENDPOINT",
|
||||
"https://example.invalid/api/projects/proj");
|
||||
try
|
||||
{
|
||||
var options = new FoundryToolboxOptions { ApiVersion = "v1" };
|
||||
options.ToolboxNames.Add("my-toolbox");
|
||||
var service = new FoundryToolboxService(
|
||||
Options.Create(options),
|
||||
Mock.Of<TokenCredential>());
|
||||
|
||||
// Act: StartAsync attempts to connect to the invalid endpoint and fails.
|
||||
// The failure path defers the toolbox; the recorded name confirms the resolver ran.
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert: open failed but the container stays routable (deferred, not bricked), and
|
||||
// the deferred name matches — i.e. we attempted the right toolbox.
|
||||
Assert.Equal(FoundryToolboxStartupStatus.Degraded, service.StartupStatus);
|
||||
Assert.Empty(service.FailedToolboxNames);
|
||||
Assert.Single(service.DeferredToolboxNames);
|
||||
Assert.Equal("my-toolbox", service.DeferredToolboxNames[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", saved);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_TrailingSlashOnProjectEndpoint_AttemptsOpenAsync()
|
||||
{
|
||||
var saved = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT");
|
||||
Environment.SetEnvironmentVariable(
|
||||
"FOUNDRY_PROJECT_ENDPOINT",
|
||||
"https://example.invalid/api/projects/proj/");
|
||||
try
|
||||
{
|
||||
var options = new FoundryToolboxOptions();
|
||||
options.ToolboxNames.Add("tb");
|
||||
var service = new FoundryToolboxService(
|
||||
Options.Create(options),
|
||||
Mock.Of<TokenCredential>());
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
// Arrange/Act: when trailing-slash normalization works the open still fails
|
||||
// (host is unreachable), but DeferredToolboxNames records the attempted name —
|
||||
// proof that the resolver did not throw on the slash and the URL was built.
|
||||
Assert.Equal(FoundryToolboxStartupStatus.Degraded, service.StartupStatus);
|
||||
Assert.Single(service.DeferredToolboxNames);
|
||||
Assert.Equal("tb", service.DeferredToolboxNames[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", saved);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_EndpointOverrideWinsOverEnvAsync()
|
||||
{
|
||||
var saved = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT");
|
||||
Environment.SetEnvironmentVariable(
|
||||
"FOUNDRY_PROJECT_ENDPOINT",
|
||||
"https://from-env.invalid/api/projects/proj");
|
||||
try
|
||||
{
|
||||
// EndpointOverride should take precedence over the env var.
|
||||
var options = new FoundryToolboxOptions
|
||||
{
|
||||
EndpointOverride = "http://127.0.0.1:1/from-override",
|
||||
};
|
||||
options.ToolboxNames.Add("tb");
|
||||
|
||||
var service = new FoundryToolboxService(
|
||||
Options.Create(options),
|
||||
Mock.Of<TokenCredential>());
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
// Override URL is unreachable; we expect Degraded (proving Start did try to open
|
||||
// a toolbox, i.e. did not fall into the NoEndpoint branch) while staying routable.
|
||||
Assert.Equal(FoundryToolboxStartupStatus.Degraded, service.StartupStatus);
|
||||
Assert.Single(service.DeferredToolboxNames);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", saved);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WithEndpointButFailingToolbox_RecordsFailureAndStaysReachableAsync()
|
||||
{
|
||||
// Arrange: a syntactically valid but unreachable endpoint forces OpenToolboxAsync
|
||||
// to throw inside the catch-and-defer path. The service must still complete StartAsync
|
||||
// (so the host doesn't crash) and keep the container routable, deferring the toolbox to
|
||||
// per-request resolution rather than failing readiness.
|
||||
var options = new FoundryToolboxOptions
|
||||
{
|
||||
EndpointOverride = "http://127.0.0.1:1/unreachable",
|
||||
};
|
||||
options.ToolboxNames.Add("broken-toolbox");
|
||||
|
||||
var service = new FoundryToolboxService(
|
||||
Options.Create(options),
|
||||
Mock.Of<TokenCredential>());
|
||||
|
||||
// Act
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(FoundryToolboxStartupStatus.Degraded, service.StartupStatus);
|
||||
Assert.Empty(service.FailedToolboxNames);
|
||||
Assert.Single(service.DeferredToolboxNames);
|
||||
Assert.Equal("broken-toolbox", service.DeferredToolboxNames[0]);
|
||||
Assert.Empty(service.Tools);
|
||||
|
||||
// A deferred (non-consent) toolbox is not a consent requirement: ConsentRequiredToolboxNames
|
||||
// must stay empty. RecomputeStatus is the single source that keeps ConsentRequiredToolboxNames
|
||||
// in sync with the pending-consent set, so they never diverge.
|
||||
Assert.Empty(service.ConsentRequiredToolboxNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryDeferredToolboxesAsync_StillUnreachable_StaysDeferredAndRoutableAsync()
|
||||
{
|
||||
// Arrange: a deferred toolbox (open failed at startup against an unreachable endpoint).
|
||||
// A per-request retry that still cannot reach the proxy must keep the toolbox deferred
|
||||
// and the container routable (Degraded), never throwing into the request pipeline.
|
||||
var options = new FoundryToolboxOptions
|
||||
{
|
||||
EndpointOverride = "http://127.0.0.1:1/unreachable",
|
||||
};
|
||||
options.ToolboxNames.Add("broken-toolbox");
|
||||
|
||||
var service = new FoundryToolboxService(
|
||||
Options.Create(options),
|
||||
Mock.Of<TokenCredential>());
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
Assert.Single(service.DeferredToolboxNames);
|
||||
|
||||
// Act: retry while the endpoint is still unreachable.
|
||||
await service.RetryDeferredToolboxesAsync(CancellationToken.None);
|
||||
|
||||
// Assert: unchanged — still deferred, still routable, no tools injected.
|
||||
Assert.Equal(FoundryToolboxStartupStatus.Degraded, service.StartupStatus);
|
||||
Assert.Single(service.DeferredToolboxNames);
|
||||
Assert.Equal("broken-toolbox", service.DeferredToolboxNames[0]);
|
||||
Assert.Empty(service.FailedToolboxNames);
|
||||
Assert.Empty(service.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WithEndpointAndNoToolboxes_ReportsHealthyAsync()
|
||||
{
|
||||
// No pre-registered toolboxes is a legitimate "lazy-only" setup. Health-check
|
||||
// should report Healthy so the readiness probe passes.
|
||||
var options = new FoundryToolboxOptions
|
||||
{
|
||||
EndpointOverride = "http://127.0.0.1:1/unused",
|
||||
};
|
||||
|
||||
var service = new FoundryToolboxService(
|
||||
Options.Create(options),
|
||||
Mock.Of<TokenCredential>());
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(FoundryToolboxStartupStatus.Healthy, service.StartupStatus);
|
||||
Assert.Empty(service.FailedToolboxNames);
|
||||
Assert.Empty(service.Tools);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("../../admin")]
|
||||
[InlineData("..%2f..%2fadmin")]
|
||||
[InlineData("%2e%2e/%2e%2e/admin")]
|
||||
[InlineData("%25252525252f")]
|
||||
[InlineData("a/b")]
|
||||
[InlineData("..")]
|
||||
[InlineData(".")]
|
||||
[InlineData("box\\..\\secret")]
|
||||
[InlineData("foo?x=1")]
|
||||
[InlineData("foo#frag")]
|
||||
[InlineData("bad%3fx=1")]
|
||||
[InlineData("tb%23frag")]
|
||||
[InlineData("a%2fb")]
|
||||
public async Task GetToolboxToolsAsync_RejectsNonSingleSegmentToolboxNameAsync(string unsafeName)
|
||||
{
|
||||
// Arrange: non-strict mode with a resolved endpoint and a benign opener seam, so a
|
||||
// well-formed single-segment name would resolve successfully. A name that would move the
|
||||
// request target — path separators, relative-path segments, a query/fragment delimiter that
|
||||
// ends the path early, or any of their percent-encoded forms — must be rejected before any
|
||||
// request URL is built, so it can never carry the managed-identity token to an unintended
|
||||
// endpoint.
|
||||
var options = new FoundryToolboxOptions
|
||||
{
|
||||
StrictMode = false,
|
||||
EndpointOverride = "https://proj.example/api/projects/proj",
|
||||
};
|
||||
await using var service = new FoundryToolboxService(
|
||||
Options.Create(options),
|
||||
Mock.Of<TokenCredential>())
|
||||
{
|
||||
ToolboxOpener = (name, _, _) => Task.FromResult(
|
||||
new FoundryToolboxService.ToolboxOpenResult(
|
||||
new FoundryToolboxService.CachedToolbox(Client: null, new HttpClient(), []),
|
||||
Consents: null)),
|
||||
};
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
// Act + Assert: resolution is refused for the caller-influenced marker name.
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await service.GetToolboxToolsAsync(unsafeName, version: null, CancellationToken.None));
|
||||
|
||||
Assert.Contains(unsafeName, ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("my-toolbox")]
|
||||
[InlineData("sales")]
|
||||
[InlineData("box.v2")]
|
||||
[InlineData("a..b")]
|
||||
[InlineData("tb:v1")]
|
||||
[InlineData("tb@1")]
|
||||
[InlineData("a(b)")]
|
||||
public async Task GetToolboxToolsAsync_AllowsWellFormedSingleSegmentNameAsync(string validName)
|
||||
{
|
||||
// Arrange: a well-formed single-segment name — including names that merely contain a
|
||||
// character which stays inside the path segment (a dot that is not a standalone relative-path
|
||||
// segment, or reserved characters such as ':' , '@' , and parentheses) — must still resolve
|
||||
// through the opener. Validation is effect-based (does the name change the request target?),
|
||||
// so it does not lock out legitimate names.
|
||||
AITool tool = AIFunctionFactory.Create(() => "ok", name: "some_tool");
|
||||
var options = new FoundryToolboxOptions
|
||||
{
|
||||
StrictMode = false,
|
||||
EndpointOverride = "https://proj.example/api/projects/proj",
|
||||
};
|
||||
await using var service = new FoundryToolboxService(
|
||||
Options.Create(options),
|
||||
Mock.Of<TokenCredential>())
|
||||
{
|
||||
ToolboxOpener = (name, _, _) => Task.FromResult(
|
||||
new FoundryToolboxService.ToolboxOpenResult(
|
||||
new FoundryToolboxService.CachedToolbox(Client: null, new HttpClient(), [tool]),
|
||||
Consents: null)),
|
||||
};
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var resolution = await service.GetToolboxToolsAsync(validName, version: null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(resolution.Consents);
|
||||
Assert.Single(resolution.Tools);
|
||||
Assert.Same(tool, resolution.Tools[0]);
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="HostedConversationKey"/>, which derives the stable per-conversation key used
|
||||
/// to map a hosted request to a persisted MAF session.
|
||||
/// </summary>
|
||||
public class HostedConversationKeyTests
|
||||
{
|
||||
// 18-char partition + 32-char entropy = 50-char body (current format).
|
||||
private const string PartitionA = "aaaaaaaaaaaaaaaa00";
|
||||
private static readonly string s_responseA = "caresp_" + PartitionA + new string('1', 32);
|
||||
private static readonly string s_responseA2 = "caresp_" + PartitionA + new string('2', 32);
|
||||
|
||||
[Fact]
|
||||
public void PartitionOf_NewFormat_ReturnsFirst18()
|
||||
=> Assert.Equal(PartitionA, HostedConversationKey.PartitionOf(s_responseA));
|
||||
|
||||
[Fact]
|
||||
public void PartitionOf_SamePartition_AcrossChain_Matches()
|
||||
=> Assert.Equal(HostedConversationKey.PartitionOf(s_responseA), HostedConversationKey.PartitionOf(s_responseA2));
|
||||
|
||||
[Fact]
|
||||
public void PartitionOf_LegacyFormat_ReturnsLast16()
|
||||
{
|
||||
var legacy = "caresp_" + new string('x', 32) + "abcdefabcdef1234"; // 48-char body
|
||||
Assert.Equal("abcdefabcdef1234", HostedConversationKey.PartitionOf(legacy));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PartitionOf_Raw_WhenNoKnownLength() => Assert.Equal("conv-123", HostedConversationKey.PartitionOf("conv-123"));
|
||||
|
||||
[Fact]
|
||||
public void PartitionOf_NullOrWhitespace_ReturnsNull()
|
||||
{
|
||||
Assert.Null(HostedConversationKey.PartitionOf(null));
|
||||
Assert.Null(HostedConversationKey.PartitionOf(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_PrefersConversation_ThenPrev_ThenResponse()
|
||||
{
|
||||
Assert.Equal("conv", HostedConversationKey.Resolve("conv", s_responseA, s_responseA2));
|
||||
Assert.Equal(PartitionA, HostedConversationKey.Resolve(null, s_responseA, "caresp_" + new string('9', 50)));
|
||||
Assert.Equal(PartitionA, HostedConversationKey.Resolve(null, null, s_responseA));
|
||||
Assert.Null(HostedConversationKey.Resolve(null, null, null));
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="HostedFoundryMemoryProviderScopes"/> built-in stateInitializer factories.
|
||||
/// </summary>
|
||||
public class HostedFoundryMemoryProviderScopesTests
|
||||
{
|
||||
private const string TestUserId = "user-isolation-key-1";
|
||||
|
||||
[Fact]
|
||||
public void PerUser_UsesUserIdAsScope()
|
||||
{
|
||||
// Arrange
|
||||
var session = CreateTaggedSession(TestUserId);
|
||||
var initializer = HostedFoundryMemoryProviderScopes.PerUser();
|
||||
|
||||
// Act
|
||||
var state = initializer(session);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(state);
|
||||
Assert.Equal(TestUserId, state.Scope.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerUser_NullSession_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var initializer = HostedFoundryMemoryProviderScopes.PerUser();
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => initializer(null));
|
||||
Assert.Contains(nameof(HostedSessionContext), ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerUser_SessionWithoutHostedContext_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var session = new BareAgentSession();
|
||||
var initializer = HostedFoundryMemoryProviderScopes.PerUser();
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => initializer(session));
|
||||
Assert.Contains(nameof(HostedFoundryMemoryProviderScopes), ex.Message);
|
||||
}
|
||||
|
||||
private static BareAgentSession CreateTaggedSession(string userId)
|
||||
{
|
||||
var session = new BareAgentSession();
|
||||
session.SetHostedContext(new HostedSessionContext(userId));
|
||||
return session;
|
||||
}
|
||||
|
||||
private sealed class BareAgentSession : AgentSession
|
||||
{
|
||||
public BareAgentSession() : base(new AgentSessionStateBag()) { }
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="HostedFoundryMemoryProviderServiceCollectionExtensions"/>.
|
||||
/// </summary>
|
||||
public class HostedFoundryMemoryProviderServiceCollectionExtensionsTests
|
||||
{
|
||||
private const string TestUserId = "ext-user-1";
|
||||
private const string MemoryStoreName = "test-memory-store";
|
||||
|
||||
[Fact]
|
||||
public void AddHostedFoundryMemoryProvider_ExplicitClient_RegistersSingleton()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var client = CreateClient();
|
||||
|
||||
// Act
|
||||
services.AddHostedFoundryMemoryProvider(client, MemoryStoreName);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
// Assert
|
||||
var first = sp.GetRequiredService<FoundryMemoryProvider>();
|
||||
var second = sp.GetRequiredService<FoundryMemoryProvider>();
|
||||
Assert.Same(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddHostedFoundryMemoryProvider_DiResolvedClient_RegistersSingleton()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(CreateClient());
|
||||
|
||||
// Act
|
||||
services.AddHostedFoundryMemoryProvider(MemoryStoreName);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
// Assert
|
||||
var first = sp.GetRequiredService<FoundryMemoryProvider>();
|
||||
var second = sp.GetRequiredService<FoundryMemoryProvider>();
|
||||
Assert.Same(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddHostedFoundryMemoryProvider_DiResolvedClient_MissingClient_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Act
|
||||
services.AddHostedFoundryMemoryProvider(MemoryStoreName);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
// Assert
|
||||
Assert.Throws<InvalidOperationException>(() => sp.GetRequiredService<FoundryMemoryProvider>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddHostedFoundryMemoryProvider_NullStateInitializer_DefaultsToPerUser()
|
||||
{
|
||||
// Arrange
|
||||
var session = CreateTaggedSession();
|
||||
|
||||
// Act
|
||||
var services = new ServiceCollection();
|
||||
services.AddHostedFoundryMemoryProvider(CreateClient(), MemoryStoreName);
|
||||
var provider = services.BuildServiceProvider().GetRequiredService<FoundryMemoryProvider>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(provider);
|
||||
var defaultInitializer = HostedFoundryMemoryProviderScopes.PerUser();
|
||||
var state = defaultInitializer(session);
|
||||
Assert.Equal(TestUserId, state.Scope.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddHostedFoundryMemoryProvider_CustomStateInitializer_IsHonored()
|
||||
{
|
||||
// Arrange
|
||||
var session = CreateTaggedSession();
|
||||
static FoundryMemoryProvider.State Custom(AgentSession? _)
|
||||
=> new(new FoundryMemoryProviderScope("custom-scope"));
|
||||
|
||||
// Act
|
||||
var services = new ServiceCollection();
|
||||
services.AddHostedFoundryMemoryProvider(CreateClient(), MemoryStoreName, Custom);
|
||||
var provider = services.BuildServiceProvider().GetRequiredService<FoundryMemoryProvider>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(provider);
|
||||
var state = Custom(session);
|
||||
Assert.Equal("custom-scope", state.Scope.Scope);
|
||||
}
|
||||
|
||||
private static AIProjectClient CreateClient()
|
||||
=> new(new Uri("https://example.services.ai.azure.com/api/projects/test"), new DefaultAzureCredential());
|
||||
|
||||
private static BareAgentSession CreateTaggedSession()
|
||||
{
|
||||
var session = new BareAgentSession();
|
||||
session.SetHostedContext(new HostedSessionContext(TestUserId));
|
||||
return session;
|
||||
}
|
||||
|
||||
private sealed class BareAgentSession : AgentSession
|
||||
{
|
||||
public BareAgentSession() : base(new AgentSessionStateBag()) { }
|
||||
}
|
||||
}
|
||||
+420
@@ -0,0 +1,420 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OpenAI;
|
||||
|
||||
#pragma warning disable OPENAI001, SCME0001, SCME0002, MEAI001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end tests that exercise the FULL hosted ASP.NET Core pipeline:
|
||||
/// inbound HTTP → MapFoundryResponses → AgentFrameworkResponseHandler → TryApplyUserAgent →
|
||||
/// agent invocation → outbound HTTP from inside the hosted environment.
|
||||
/// Verifies that the hosted-agent <c>User-Agent</c> supplement reaches the outbound wire,
|
||||
/// not just the inbound request.
|
||||
/// </summary>
|
||||
public sealed class HostedOutboundUserAgentTests : IAsyncDisposable
|
||||
{
|
||||
private const string TestEndpoint = "https://fake-foundry.example.com/api/projects/fake-prj";
|
||||
private const string Deployment = "fake-deployment";
|
||||
|
||||
private WebApplication? _app;
|
||||
private HttpClient? _inboundClient;
|
||||
private RecordingHandler? _outboundHandler;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
this._inboundClient?.Dispose();
|
||||
this._outboundHandler?.Dispose();
|
||||
if (this._app is not null)
|
||||
{
|
||||
await this._app.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Hosted_InboundResponsesRequest_TriggersOutboundCall_WithFoundryHostingSupplementAsync()
|
||||
{
|
||||
// Arrange: spin up a real ASP.NET Core TestServer that hosts an AIAgent backed by MEAI's
|
||||
// OpenAIResponsesChatClient → ProjectResponsesClient → fake HTTP transport. This is the
|
||||
// exact production stack minus the network: the only thing not real is the wire transport.
|
||||
await this.StartHostedServerAsync();
|
||||
|
||||
// Act: send an inbound /openai/v1/responses request as the Foundry runtime would.
|
||||
using var inboundRequest = new HttpRequestMessage(HttpMethod.Post, "/responses")
|
||||
{
|
||||
Content = new StringContent(InboundResponsesRequestJson(), Encoding.UTF8, "application/json"),
|
||||
};
|
||||
using var inboundResponse = await this._inboundClient!.SendAsync(inboundRequest);
|
||||
var inboundBody = await inboundResponse.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert: at least one OUTBOUND request reached the fake transport, AND it carries the
|
||||
// combined hosted segment foundry-hosting/agent-framework-dotnet/{version} on its
|
||||
// User-Agent. This matches Python's contract
|
||||
// (foundry-hosting/agent-framework-python/{version}, see
|
||||
// python/packages/core/agent_framework/_telemetry.py): a single combined segment when
|
||||
// hosted, never two separate ones. The bare agent-framework-dotnet/{version} segment
|
||||
// (from AgentFrameworkUserAgentPolicy in FoundryChatClient) must be upgraded in place
|
||||
// by HostedAgentUserAgentPolicy — never appear duplicated.
|
||||
Assert.True(this._outboundHandler!.Requests.Count > 0,
|
||||
$"Expected at least one outbound request. Inbound status: {(int)inboundResponse.StatusCode}, body: {inboundBody}");
|
||||
var outbound = this._outboundHandler.Requests[0];
|
||||
Assert.StartsWith(TestEndpoint, outbound.Uri);
|
||||
Assert.Contains("MEAI/", outbound.UserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet/", outbound.UserAgent);
|
||||
|
||||
// The bare agent-framework-dotnet/{v} segment must NOT appear separately when the
|
||||
// combined form is present — Python emits a single combined value when the hosted
|
||||
// prefix is registered, and .NET preserves that contract via the in-place upgrade in
|
||||
// HostedAgentUserAgentPolicy.
|
||||
var combinedIdx = outbound.UserAgent!.IndexOf("foundry-hosting/agent-framework-dotnet/", StringComparison.Ordinal);
|
||||
var beforeCombined = outbound.UserAgent.Substring(0, combinedIdx);
|
||||
var afterCombined = outbound.UserAgent.Substring(combinedIdx + "foundry-hosting/agent-framework-dotnet/".Length);
|
||||
Assert.DoesNotContain("agent-framework-dotnet/", beforeCombined);
|
||||
Assert.DoesNotContain("agent-framework-dotnet/", afterCombined);
|
||||
}
|
||||
|
||||
private async Task StartHostedServerAsync()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
// Build a real ChatClientAgent whose IChatClient is MEAI's OpenAIResponsesChatClient
|
||||
// wrapping a ProjectResponsesClient backed by a fake HTTP handler. After AgentFrameworkResponseHandler
|
||||
// resolves this agent, TryApplyUserAgent will swap the inner _responseClient with our wrapper.
|
||||
this._outboundHandler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
var outboundHttpClient = new HttpClient(this._outboundHandler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var projectOptions = new ProjectResponsesClientOptions
|
||||
{
|
||||
Transport = new HttpClientPipelineTransport(outboundHttpClient),
|
||||
};
|
||||
var projectResponsesClient = new ProjectResponsesClient(
|
||||
new Uri(TestEndpoint),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
projectOptions);
|
||||
|
||||
IChatClient chatClient = projectResponsesClient.AsIChatClient(Deployment);
|
||||
AIAgent agent = new ChatClientAgent(chatClient);
|
||||
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
builder.Services.AddLogging();
|
||||
|
||||
this._app = builder.Build();
|
||||
this._app.MapFoundryResponses();
|
||||
|
||||
await this._app.StartAsync();
|
||||
|
||||
var testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
|
||||
?? throw new InvalidOperationException("TestServer not found");
|
||||
|
||||
this._inboundClient = testServer.CreateClient();
|
||||
}
|
||||
|
||||
private static string InboundResponsesRequestJson() => """
|
||||
{
|
||||
"model": "fake-deployment",
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_1",
|
||||
"status": "completed",
|
||||
"role": "user",
|
||||
"content": [{ "type": "input_text", "text": "Hello" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
private static string MinimalResponseJson() => """
|
||||
{
|
||||
"id":"resp_1","object":"response","created_at":1700000000,"status":"completed",
|
||||
"model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public void TryApplyUserAgent_RepeatedCalls_OnSameAgent_RegistersPolicyOnce()
|
||||
{
|
||||
// Arrange: hosted resolution calls TryApplyUserAgent on every request. Without per-instance
|
||||
// dedup, each call would append another policy entry to the shared OpenAIRequestPolicies,
|
||||
// producing unbounded growth on singleton agents (one chat client reused across requests).
|
||||
using var http = new HttpClient(new NoopHandler());
|
||||
var openAIClient = new OpenAIClient(new ApiKeyCredential("fake"),
|
||||
new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) });
|
||||
IChatClient chatClient = openAIClient.GetResponsesClient().AsIChatClient();
|
||||
AIAgent agent = new ChatClientAgent(chatClient);
|
||||
|
||||
// Act
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
FoundryHostingExtensions.TryApplyUserAgent(agent);
|
||||
}
|
||||
|
||||
// Assert: exactly one HostedAgentUserAgentPolicy entry on the shared OpenAIRequestPolicies.
|
||||
var policies = chatClient.GetService<OpenAIRequestPolicies>();
|
||||
Assert.NotNull(policies);
|
||||
Assert.Equal(1, EntriesCount(policies!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyUserAgent_AcrossDistinctAgents_RegistersPolicyOncePerChatClient()
|
||||
{
|
||||
// Arrange: dedup is per-OpenAIRequestPolicies-instance, not global, so two agents on
|
||||
// different chat clients each get exactly one registration.
|
||||
using var http1 = new HttpClient(new NoopHandler());
|
||||
using var http2 = new HttpClient(new NoopHandler());
|
||||
var client1 = new OpenAIClient(new ApiKeyCredential("k1"),
|
||||
new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http1) });
|
||||
var client2 = new OpenAIClient(new ApiKeyCredential("k2"),
|
||||
new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http2) });
|
||||
|
||||
IChatClient cc1 = client1.GetResponsesClient().AsIChatClient();
|
||||
IChatClient cc2 = client2.GetResponsesClient().AsIChatClient();
|
||||
AIAgent a1 = new ChatClientAgent(cc1);
|
||||
AIAgent a2 = new ChatClientAgent(cc2);
|
||||
|
||||
// Act
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
FoundryHostingExtensions.TryApplyUserAgent(a1);
|
||||
FoundryHostingExtensions.TryApplyUserAgent(a2);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, EntriesCount(cc1.GetService<OpenAIRequestPolicies>()!));
|
||||
Assert.Equal(1, EntriesCount(cc2.GetService<OpenAIRequestPolicies>()!));
|
||||
}
|
||||
|
||||
private static int EntriesCount(OpenAIRequestPolicies policies)
|
||||
{
|
||||
var field = typeof(OpenAIRequestPolicies).GetField("_entries", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||
var array = (Array?)field?.GetValue(policies);
|
||||
return array?.Length ?? -1;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Direct unit tests for HostedAgentUserAgentPolicy's in-place upgrade behavior.
|
||||
// These run the policy on a synthetic ClientPipeline (no hosting infrastructure)
|
||||
// so the upgrade logic itself can be asserted in isolation.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task HostedAgentUserAgentPolicy_UpgradesBareAgentFrameworkSegment_InPlaceAsync()
|
||||
{
|
||||
// Arrange: an upstream per-call policy stamps the bare agent-framework-dotnet/{version}
|
||||
// segment (matching what AgentFrameworkUserAgentPolicy would write in non-hosted code).
|
||||
// Then HostedAgentUserAgentPolicy runs and must REPLACE that segment with the combined
|
||||
// foundry-hosting/agent-framework-dotnet/{version} form, not append a duplicate.
|
||||
using var handler = new InspectingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [new SetUserAgentPolicy("agent-framework-dotnet/9.9.9"), HostedAgentUserAgentPolicy.Instance],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert: combined form is present; bare form is gone (no duplicate agent-framework segment).
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet/", handler.LastUserAgent);
|
||||
var ua = handler.LastUserAgent!;
|
||||
var firstAgentFramework = ua.IndexOf("agent-framework-dotnet/", StringComparison.Ordinal);
|
||||
Assert.True(firstAgentFramework >= 0, "Expected agent-framework-dotnet segment.");
|
||||
var secondAgentFramework = ua.IndexOf("agent-framework-dotnet/", firstAgentFramework + 1, StringComparison.Ordinal);
|
||||
Assert.Equal(-1, secondAgentFramework);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostedAgentUserAgentPolicy_AppendsCombined_WhenNoBareSegmentPresentAsync()
|
||||
{
|
||||
// Arrange: nothing upstream stamps the bare segment. Hosted policy should append the
|
||||
// full combined segment to whatever User-Agent is on the wire.
|
||||
using var handler = new InspectingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [HostedAgentUserAgentPolicy.Instance],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet/", handler.LastUserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostedAgentUserAgentPolicy_IsIdempotent_WhenCombinedSegmentAlreadyPresentAsync()
|
||||
{
|
||||
// Arrange: upstream pre-populates the combined segment (simulating a retry or duplicate
|
||||
// registration). Hosted policy must not re-append.
|
||||
using var handler = new InspectingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [new SetUserAgentPolicy("foundry-hosting/agent-framework-dotnet/9.9.9"), HostedAgentUserAgentPolicy.Instance],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert: exactly one occurrence of "foundry-hosting/agent-framework-dotnet/" segment.
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
var first = handler.LastUserAgent!.IndexOf("foundry-hosting/agent-framework-dotnet/", StringComparison.Ordinal);
|
||||
Assert.True(first >= 0);
|
||||
var second = handler.LastUserAgent.IndexOf("foundry-hosting/agent-framework-dotnet/", first + 1, StringComparison.Ordinal);
|
||||
Assert.Equal(-1, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostedAgentUserAgentPolicy_ReplacesDifferentVersionCombinedSegment_InPlaceAsync()
|
||||
{
|
||||
// Q-D regression: when the User-Agent already carries the COMBINED hosted form with a
|
||||
// different version (e.g. an older registration or caller-supplied baseline), the policy
|
||||
// must replace the entire combined span — not just the bare suffix — so we never emit
|
||||
// the malformed `foundry-hosting/foundry-hosting/agent-framework-dotnet/...` shape.
|
||||
using var handler = new InspectingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [new SetUserAgentPolicy("foundry-hosting/agent-framework-dotnet/0.0.1 MEAI/10.5.1"), HostedAgentUserAgentPolicy.Instance],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert: no doubled foundry-hosting/ prefix.
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
Assert.DoesNotContain("foundry-hosting/foundry-hosting/", handler.LastUserAgent, StringComparison.Ordinal);
|
||||
|
||||
// The combined segment must appear exactly once, and the trailing MEAI segment must be
|
||||
// preserved in place (i.e. the policy only rewrote the combined span, not anything after it).
|
||||
var firstCombined = handler.LastUserAgent!.IndexOf("foundry-hosting/agent-framework-dotnet/", StringComparison.Ordinal);
|
||||
Assert.True(firstCombined >= 0);
|
||||
var secondCombined = handler.LastUserAgent.IndexOf("foundry-hosting/agent-framework-dotnet/", firstCombined + 1, StringComparison.Ordinal);
|
||||
Assert.Equal(-1, secondCombined);
|
||||
Assert.Contains(" MEAI/10.5.1", handler.LastUserAgent, StringComparison.Ordinal);
|
||||
|
||||
// And the version that survives must be the runtime supplement value's version, not 0.0.1.
|
||||
Assert.DoesNotContain("foundry-hosting/agent-framework-dotnet/0.0.1", handler.LastUserAgent, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private sealed class InspectingHandler : HttpClientHandler
|
||||
{
|
||||
public string? LastUserAgent { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
this.LastUserAgent = request.Headers.TryGetValues("User-Agent", out var values)
|
||||
? string.Join(",", values)
|
||||
: null;
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
|
||||
RequestMessage = request,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SetUserAgentPolicy : PipelinePolicy
|
||||
{
|
||||
private readonly string _value;
|
||||
public SetUserAgentPolicy(string value) => this._value = value;
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Set("User-Agent", this._value);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Set("User-Agent", this._value);
|
||||
return ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpClientHandler
|
||||
{
|
||||
private readonly string _body;
|
||||
public List<RecordedRequest> Requests { get; } = [];
|
||||
|
||||
public RecordingHandler(string body)
|
||||
{
|
||||
this._body = body;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
string ua = request.Headers.TryGetValues("User-Agent", out var values)
|
||||
? string.Join(",", values)
|
||||
: "(none)";
|
||||
this.Requests.Add(new RecordedRequest(request.RequestUri?.ToString() ?? "?", ua));
|
||||
|
||||
var resp = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(this._body, Encoding.UTF8, "application/json"),
|
||||
RequestMessage = request,
|
||||
};
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct RecordedRequest(string Uri, string UserAgent);
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="HostedProtocolCompatibility"/>, the protocol-version gate that turns an
|
||||
/// opaque 500 into a clear 501 when this 2.0.0-only image is served container protocol 1.0.0.
|
||||
/// </summary>
|
||||
public sealed class HostedProtocolCompatibilityTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetUnsupportedProtocolError_HostedWithoutCallId_ReturnsUnsupportedProtocolError()
|
||||
{
|
||||
// Hosted by Foundry (FoundryEnvironment.IsHosted == true) but no x-agent-foundry-call-id header
|
||||
// means the platform is talking protocol 1.0.0 to a 2.0.0-only image.
|
||||
ResponsesApiException? error = HostedProtocolCompatibility.GetUnsupportedProtocolError(isHosted: true, callId: null);
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal(HostedProtocolCompatibility.UnsupportedProtocolStatusCode, error!.StatusCode);
|
||||
Assert.Equal(501, error.StatusCode);
|
||||
Assert.Equal(HostedProtocolCompatibility.UnsupportedProtocolErrorCode, error.Error.Code);
|
||||
Assert.Equal("Unsupported responses protocol version. This agent requires responses protocol v2.0.0", error.Error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetUnsupportedProtocolError_HostedWithEmptyCallId_ReturnsUnsupportedProtocolError()
|
||||
{
|
||||
// An empty or whitespace-only header value is treated the same as an absent one, so a proxy that
|
||||
// injects whitespace cannot bypass the gate.
|
||||
foreach (var callId in new[] { "", " ", "\t" })
|
||||
{
|
||||
ResponsesApiException? error = HostedProtocolCompatibility.GetUnsupportedProtocolError(isHosted: true, callId: callId);
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal(501, error!.StatusCode);
|
||||
Assert.Equal(HostedProtocolCompatibility.UnsupportedProtocolErrorCode, error.Error.Code);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetUnsupportedProtocolError_HostedWithCallId_ReturnsNull()
|
||||
{
|
||||
// Protocol 2.0.0: the platform supplies x-agent-foundry-call-id, so the request is compatible.
|
||||
ResponsesApiException? error = HostedProtocolCompatibility.GetUnsupportedProtocolError(isHosted: true, callId: "fcid_abc123");
|
||||
|
||||
Assert.Null(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetUnsupportedProtocolError_NotHosted_ReturnsNull()
|
||||
{
|
||||
// Local development (not hosted by Foundry) is never flagged, regardless of the call id.
|
||||
Assert.Null(HostedProtocolCompatibility.GetUnsupportedProtocolError(isHosted: false, callId: null));
|
||||
Assert.Null(HostedProtocolCompatibility.GetUnsupportedProtocolError(isHosted: false, callId: "fcid_abc123"));
|
||||
}
|
||||
}
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests covering the per-session identity context that <see cref="AgentFrameworkResponseHandler"/>
|
||||
/// applies via the registered <see cref="HostedSessionIsolationKeyProvider"/>.
|
||||
/// </summary>
|
||||
public class HostedSessionIdentityContextTests
|
||||
{
|
||||
private const string TestUserId = "user-isolation-key-1";
|
||||
|
||||
[Fact]
|
||||
public void HostedSessionContext_RejectsNullOrWhitespaceKeys()
|
||||
{
|
||||
// Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new HostedSessionContext(null!));
|
||||
Assert.Throws<ArgumentException>(() => new HostedSessionContext(string.Empty));
|
||||
Assert.Throws<ArgumentException>(() => new HostedSessionContext(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlatformProvider_MapsIsolationContextValuesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new PlatformHostedSessionIsolationKeyProvider();
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mockContext.Setup(x => x.PlatformContext).Returns(new PlatformContext(TestUserId, "call-1"));
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
|
||||
// Act
|
||||
var result = await provider.GetKeysAsync(mockContext.Object, request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(TestUserId, result.UserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlatformProvider_ReturnsNullWhenIsolationKeysAreEmptyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new PlatformHostedSessionIsolationKeyProvider();
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
// CallBase delegates to ResponseContext.PlatformContext default which is PlatformContext.Empty.
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
|
||||
// Act
|
||||
var result = await provider.GetKeysAsync(mockContext.Object, request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handler_FreshSession_AppliesContextFromCustomProviderAsync()
|
||||
{
|
||||
// Arrange
|
||||
var capturingAgent = new HostedContextCapturingAgent();
|
||||
var fakeProvider = new FakeHostedSessionIsolationKeyProvider("alice");
|
||||
var handler = BuildHandler(capturingAgent, fakeProvider);
|
||||
|
||||
var (request, mockContext) = BuildFreshRequest();
|
||||
|
||||
// Act
|
||||
await DrainAsync(handler.CreateAsync(request, mockContext.Object, CancellationToken.None));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturingAgent.LastSession);
|
||||
var ctx = capturingAgent.LastSession.GetHostedContext();
|
||||
Assert.NotNull(ctx);
|
||||
Assert.Equal("alice", ctx.UserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handler_NullKeysFromProvider_NotHosted_SucceedsWithoutContextAsync()
|
||||
{
|
||||
// Arrange: a provider that returns null keys (as the default platform provider does locally when
|
||||
// no x-agent-user-id header is present). Under unit tests FoundryEnvironment.IsHosted is false, so
|
||||
// the container is treated as local: the request must proceed with per-user isolation not triggered
|
||||
// rather than 500ing. No hosted context is stamped on the session.
|
||||
var capturingAgent = new HostedContextCapturingAgent();
|
||||
var fakeProvider = new FakeHostedSessionIsolationKeyProvider(userId: null);
|
||||
var handler = BuildHandler(capturingAgent, fakeProvider);
|
||||
|
||||
var (request, mockContext) = BuildFreshRequest();
|
||||
|
||||
// Act
|
||||
await DrainAsync(handler.CreateAsync(request, mockContext.Object, CancellationToken.None));
|
||||
|
||||
// Assert: no throw, a session was produced, and it carries no hosted identity context.
|
||||
Assert.NotNull(capturingAgent.LastSession);
|
||||
Assert.Null(capturingAgent.LastSession.GetHostedContext());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handler_ResumeSession_MatchingKeys_PassesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var capturingAgent = new HostedContextCapturingAgent();
|
||||
var fakeProvider = new FakeHostedSessionIsolationKeyProvider("alice");
|
||||
var sessionStore = new InMemoryAgentSessionStore();
|
||||
var handler = BuildHandler(capturingAgent, fakeProvider, sessionStore);
|
||||
|
||||
// Step 1: drive a fresh request to populate the session store with a tagged session.
|
||||
var (freshRequest, freshContext) = BuildFreshRequest();
|
||||
await DrainAsync(handler.CreateAsync(freshRequest, freshContext.Object, CancellationToken.None));
|
||||
Assert.NotNull(capturingAgent.LastSession);
|
||||
|
||||
// Step 2: persist the session under a known conversation id (mimics what the handler does
|
||||
// when it has a conversation id; here we plant it directly so we can drive a resume request).
|
||||
// The session is scoped to the same user ("alice") that will resume it.
|
||||
const string ConversationId = "resume-chat-id";
|
||||
await sessionStore.SaveSessionAsync(capturingAgent, ConversationId, capturingAgent.LastSession, "alice", CancellationToken.None);
|
||||
|
||||
// Step 3: drive a resume request with the same isolation keys.
|
||||
var (resumeRequest, resumeContext) = BuildResumeRequest(ConversationId);
|
||||
capturingAgent.LastSession = null;
|
||||
|
||||
// Act
|
||||
await DrainAsync(handler.CreateAsync(resumeRequest, resumeContext.Object, CancellationToken.None));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturingAgent.LastSession);
|
||||
var ctx = capturingAgent.LastSession.GetHostedContext();
|
||||
Assert.NotNull(ctx);
|
||||
Assert.Equal("alice", ctx.UserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handler_ResumeSession_MismatchedUserId_Returns403Async()
|
||||
{
|
||||
// Arrange
|
||||
var capturingAgent = new HostedContextCapturingAgent();
|
||||
var aliceProvider = new FakeHostedSessionIsolationKeyProvider("alice");
|
||||
var sessionStore = new InMemoryAgentSessionStore();
|
||||
var aliceHandler = BuildHandler(capturingAgent, aliceProvider, sessionStore);
|
||||
|
||||
var (freshRequest, freshContext) = BuildFreshRequest();
|
||||
await DrainAsync(aliceHandler.CreateAsync(freshRequest, freshContext.Object, CancellationToken.None));
|
||||
const string ConversationId = "resume-chat-id";
|
||||
|
||||
// Plant Alice's stamped session UNDER BOB'S partition to simulate a session that reached Bob's
|
||||
// key despite the per-user path partitioning (e.g. a non-partitioning custom store, or in-process
|
||||
// tampering). The 403 identity check is the defense-in-depth layer that must still reject it even
|
||||
// when the physical partition was bypassed.
|
||||
await sessionStore.SaveSessionAsync(capturingAgent, ConversationId, capturingAgent.LastSession!, "bob", CancellationToken.None);
|
||||
|
||||
// Bob attempts to resume Alice's conversation.
|
||||
var bobProvider = new FakeHostedSessionIsolationKeyProvider("bob");
|
||||
var bobHandler = BuildHandler(capturingAgent, bobProvider, sessionStore);
|
||||
var (resumeRequest, resumeContext) = BuildResumeRequest(ConversationId);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<ResponsesApiException>(() => DrainAsync(bobHandler.CreateAsync(resumeRequest, resumeContext.Object, CancellationToken.None)));
|
||||
Assert.Equal(403, ex.StatusCode);
|
||||
Assert.Equal("Hosted session identity context mismatch", ex.Error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handler_ResumeSession_WithoutPriorContext_StampsAsFreshAsync()
|
||||
{
|
||||
// Arrange: store an untagged session. This case arises in production when the platform
|
||||
// (or the caller) creates a Foundry conversation_id externally, and the very first
|
||||
// hosted-agent request for that conversation hits the handler before any context is
|
||||
// stamped. Such a session is treated as "fresh" rather than "resume" because there is
|
||||
// no prior identity to defend; the stamp made now is what future resumes will validate.
|
||||
var capturingAgent = new HostedContextCapturingAgent();
|
||||
var sessionStore = new InMemoryAgentSessionStore();
|
||||
const string ConversationId = "untagged-chat-id";
|
||||
var untagged = await capturingAgent.CreateSessionAsync(CancellationToken.None);
|
||||
await sessionStore.SaveSessionAsync(capturingAgent, ConversationId, untagged, "alice", CancellationToken.None);
|
||||
|
||||
var fakeProvider = new FakeHostedSessionIsolationKeyProvider("alice");
|
||||
var handler = BuildHandler(capturingAgent, fakeProvider, sessionStore);
|
||||
var (resumeRequest, resumeContext) = BuildResumeRequest(ConversationId);
|
||||
|
||||
// Act
|
||||
await DrainAsync(handler.CreateAsync(resumeRequest, resumeContext.Object, CancellationToken.None));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturingAgent.LastSession);
|
||||
var ctx = capturingAgent.LastSession.GetHostedContext();
|
||||
Assert.NotNull(ctx);
|
||||
Assert.Equal("alice", ctx.UserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHostedContext_ReturnsNullWhenAbsent()
|
||||
{
|
||||
// Arrange
|
||||
var session = new HostedContextCapturingSession();
|
||||
|
||||
// Act
|
||||
var ctx = session.GetHostedContext();
|
||||
|
||||
// Assert
|
||||
Assert.Null(ctx);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetHostedContext_ThenGet_RoundTrips()
|
||||
{
|
||||
// Arrange
|
||||
var session = new HostedContextCapturingSession();
|
||||
|
||||
// Act
|
||||
session.SetHostedContext(new HostedSessionContext("alice"));
|
||||
var ctx = session.GetHostedContext();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(ctx);
|
||||
Assert.Equal("alice", ctx.UserId);
|
||||
}
|
||||
|
||||
private static AgentFrameworkResponseHandler BuildHandler(
|
||||
AIAgent agent,
|
||||
HostedSessionIsolationKeyProvider provider,
|
||||
AgentSessionStore? sessionStore = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(sessionStore ?? new InMemoryAgentSessionStore());
|
||||
services.AddSingleton(agent);
|
||||
services.AddSingleton(provider);
|
||||
var sp = services.BuildServiceProvider();
|
||||
return new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
}
|
||||
|
||||
private static (CreateResponse Request, Mock<ResponseContext> Context) BuildFreshRequest()
|
||||
{
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
content = new[] { new { type = "input_text", text = "Hello" } } }
|
||||
});
|
||||
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<Item>());
|
||||
return (request, mockContext);
|
||||
}
|
||||
|
||||
private static (CreateResponse Request, Mock<ResponseContext> Context) BuildResumeRequest(string conversationId)
|
||||
{
|
||||
var (request, mockContext) = BuildFreshRequest();
|
||||
request.Conversation = BinaryData.FromString($"\"{conversationId}\"");
|
||||
return (request, mockContext);
|
||||
}
|
||||
|
||||
private static async Task DrainAsync(IAsyncEnumerable<ResponseStreamEvent> stream)
|
||||
{
|
||||
await foreach (var _ in stream)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal <see cref="AIAgent"/> subclass that captures the session it was invoked with so tests
|
||||
/// can inspect the <see cref="HostedSessionContext"/> applied by the handler.
|
||||
/// </summary>
|
||||
private sealed class HostedContextCapturingAgent : AIAgent
|
||||
{
|
||||
public AgentSession? LastSession { get; set; }
|
||||
|
||||
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.LastSession = session;
|
||||
return ToAsyncEnumerableAsync(new AgentResponseUpdate
|
||||
{
|
||||
MessageId = "resp_msg_1",
|
||||
Contents = [new Extensions.AI.TextContent("ok")]
|
||||
});
|
||||
}
|
||||
|
||||
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) =>
|
||||
new(new HostedContextCapturingSession());
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
new(((HostedContextCapturingSession)session).Serialize());
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
new(HostedContextCapturingSession.Deserialize(serializedState));
|
||||
|
||||
private static async IAsyncEnumerable<AgentResponseUpdate> ToAsyncEnumerableAsync(params AgentResponseUpdate[] items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal session implementation that round-trips its <see cref="AgentSessionStateBag"/> via JSON.
|
||||
/// </summary>
|
||||
private sealed class HostedContextCapturingSession : AgentSession
|
||||
{
|
||||
public HostedContextCapturingSession()
|
||||
{
|
||||
}
|
||||
|
||||
private HostedContextCapturingSession(AgentSessionStateBag bag)
|
||||
{
|
||||
this.StateBag = bag;
|
||||
}
|
||||
|
||||
public JsonElement Serialize() => this.StateBag.Serialize();
|
||||
|
||||
public static HostedContextCapturingSession Deserialize(JsonElement element)
|
||||
=> new(AgentSessionStateBag.Deserialize(element));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+23
@@ -0,0 +1,23 @@
|
||||
<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" />
|
||||
<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>
|
||||
|
||||
</Project>
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
public class OAuthConsentEmissionTests
|
||||
{
|
||||
private static ResponseEventStream CreateTestStream()
|
||||
{
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
var request = new CreateResponse { Model = "test-model" };
|
||||
return new ResponseEventStream(mockContext.Object, request);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmitOAuthConsentRequest_EmitsOAuthConsentRequestItem_NotMcpApproval()
|
||||
{
|
||||
// Arrange
|
||||
const string ConsentUrl = "https://login.microsoftonline.com/consent?data=abc";
|
||||
const string ServerLabel = "outlook_mail";
|
||||
var stream = CreateTestStream();
|
||||
|
||||
// Act: emit the consent request (added → done).
|
||||
List<ResponseStreamEvent> events =
|
||||
AgentFrameworkResponseHandler.EmitOAuthConsentRequest(stream, ServerLabel, ConsentUrl).ToList();
|
||||
|
||||
// Assert: exactly an output_item.added followed by output_item.done, carrying an
|
||||
// oauth_consent_request item with the consent link and server label. A valid wire id is
|
||||
// required by AddOutputItem; if the generated id were malformed this call would have thrown.
|
||||
Assert.Equal(2, events.Count);
|
||||
var added = Assert.IsType<ResponseOutputItemAddedEvent>(events[0]);
|
||||
var done = Assert.IsType<ResponseOutputItemDoneEvent>(events[1]);
|
||||
|
||||
var addedItem = Assert.IsType<OAuthConsentRequestOutputItem>(added.Item);
|
||||
Assert.Equal(ConsentUrl, addedItem.ConsentLink);
|
||||
Assert.Equal(ServerLabel, addedItem.ServerLabel);
|
||||
Assert.StartsWith("oacr_", addedItem.Id);
|
||||
|
||||
var doneItem = Assert.IsType<OAuthConsentRequestOutputItem>(done.Item);
|
||||
Assert.Equal(addedItem.Id, doneItem.Id);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+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 (lone FCC = HITL request) + 1 text = 3
|
||||
// Total: 7 output items
|
||||
Assert.Equal(7, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Single(events.OfType<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;
|
||||
}
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Moq;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
public class ServiceCollectionExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddFoundryResponses_RegistersResponseHandler()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
services.AddFoundryResponses();
|
||||
|
||||
var descriptor = services.FirstOrDefault(
|
||||
d => d.ServiceType == typeof(ResponseHandler));
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(typeof(AgentFrameworkResponseHandler), descriptor.ImplementationType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddFoundryResponses_CalledTwice_RegistersOnce()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
services.AddFoundryResponses();
|
||||
services.AddFoundryResponses();
|
||||
|
||||
var count = services.Count(d => d.ServiceType == typeof(ResponseHandler));
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddFoundryResponses_NullServices_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => FoundryHostingExtensions.AddFoundryResponses(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddFoundryResponses_WithAgent_RegistersAgentAndHandler()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
|
||||
services.AddFoundryResponses(mockAgent.Object);
|
||||
|
||||
var handlerDescriptor = services.FirstOrDefault(
|
||||
d => d.ServiceType == typeof(ResponseHandler));
|
||||
Assert.NotNull(handlerDescriptor);
|
||||
|
||||
var agentDescriptor = services.FirstOrDefault(
|
||||
d => d.ServiceType == typeof(AIAgent));
|
||||
Assert.NotNull(agentDescriptor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddFoundryResponses_WithNullAgent_ThrowsArgumentNullException()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => services.AddFoundryResponses(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyOpenTelemetry_NonInstrumentedAgent_WrapsWithOpenTelemetryAgent()
|
||||
{
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
|
||||
var result = FoundryHostingExtensions.ApplyOpenTelemetry(mockAgent.Object);
|
||||
|
||||
Assert.NotNull(result.GetService<OpenTelemetryAgent>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyOpenTelemetry_AlreadyInstrumentedAgent_ReturnsSameReference()
|
||||
{
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
var instrumented = mockAgent.Object.AsBuilder()
|
||||
.UseOpenTelemetry()
|
||||
.Build();
|
||||
|
||||
var result = FoundryHostingExtensions.ApplyOpenTelemetry(instrumented);
|
||||
|
||||
Assert.Same(instrumented, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyUserAgent_AgentWithoutChatClient_NoOp()
|
||||
{
|
||||
// Arrange: agent.GetService<IChatClient>() returns null.
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
|
||||
// Act
|
||||
var result = FoundryHostingExtensions.TryApplyUserAgent(mockAgent.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Same(mockAgent.Object, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyUserAgent_AgentWithNonMeaiChatClient_NoOp()
|
||||
{
|
||||
// Arrange: chat client that does not return MEAI's OpenAIResponsesChatClient via GetService.
|
||||
var mockChatClient = new Mock<IChatClient>();
|
||||
mockChatClient.Setup(c => c.GetService(It.IsAny<Type>(), It.IsAny<object?>())).Returns(null!);
|
||||
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
mockAgent.Setup(a => a.GetService(typeof(IChatClient), It.IsAny<object?>())).Returns(mockChatClient.Object);
|
||||
|
||||
// Act
|
||||
var result = FoundryHostingExtensions.TryApplyUserAgent(mockAgent.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Same(mockAgent.Object, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeaiOpenAIResponsesChatClient_TypeFullName_ReflectionGuard()
|
||||
{
|
||||
// Guards the polyfill's reflection target type-name.
|
||||
var meaiType = typeof(MicrosoftExtensionsAIResponsesExtensions).Assembly
|
||||
.GetType("Microsoft.Extensions.AI.OpenAIResponsesChatClient");
|
||||
Assert.NotNull(meaiType);
|
||||
Assert.True(typeof(IChatClient).IsAssignableFrom(meaiType!),
|
||||
$"Expected MEAI {meaiType!.FullName} to implement IChatClient.");
|
||||
}
|
||||
|
||||
// ── /readiness auto-mapping (Foundry container-image-spec §2) ────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task MapFoundryResponses_MapsReadinessEndpoint_WhenTier3HostHasNotMappedItAsync()
|
||||
{
|
||||
// Arrange: Tier 3 host (WebApplication.CreateBuilder, no AgentHost) — Core SDK does
|
||||
// NOT map /readiness in this case, so MapFoundryResponses must cover the gap.
|
||||
using var host = await BuildTestHostAsync(static app => app.MapFoundryResponses());
|
||||
|
||||
// Act
|
||||
var response = await host.GetTestClient().GetAsync(new Uri("/readiness", UriKind.Relative));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapFoundryResponses_DoesNotDuplicateReadiness_WhenAlreadyMappedAsync()
|
||||
{
|
||||
// Arrange: developer already mapped /readiness with a custom body. The auto-map
|
||||
// must detect the existing route and leave it untouched (no AmbiguousMatchException
|
||||
// at runtime, no override of the developer's response).
|
||||
const string CustomBody = "ready-from-developer";
|
||||
using var host = await BuildTestHostAsync(static app =>
|
||||
{
|
||||
app.MapGet("/readiness", () => Results.Text("ready-from-developer"));
|
||||
app.MapFoundryResponses();
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await host.GetTestClient().GetAsync(new Uri("/readiness", UriKind.Relative));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.Equal(CustomBody, body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapFoundryResponses_CalledTwice_StillOnlyMapsReadinessOnceAsync()
|
||||
{
|
||||
// Arrange: defensive coverage for callers that map the responses pipeline twice
|
||||
// (e.g. once at the root and once under "openai/v1" in the existing AF samples).
|
||||
using var host = await BuildTestHostAsync(static app =>
|
||||
{
|
||||
app.MapFoundryResponses();
|
||||
app.MapFoundryResponses("openai/v1");
|
||||
});
|
||||
|
||||
// Act + Assert: a single GET /readiness must succeed without ambiguous-match throw.
|
||||
var response = await host.GetTestClient().GetAsync(new Uri("/readiness", UriKind.Relative));
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
private static async Task<IHost> BuildTestHostAsync(Action<WebApplication> configure)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
mockAgent.SetupGet(a => a.Name).Returns("test-agent");
|
||||
builder.Services.AddFoundryResponses(mockAgent.Object);
|
||||
|
||||
var app = builder.Build();
|
||||
configure(app);
|
||||
await app.StartAsync();
|
||||
return app;
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
public class ToolboxConsentParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryParseConsentRequired_SingleConsentError_ReturnsTrueWithUrlAndToolName()
|
||||
{
|
||||
// Arrange: the exact aggregate tools/list failure shape the proxy returns when a
|
||||
// single tool source needs OAuth consent before it can be enumerated.
|
||||
const string Message =
|
||||
"Request failed (remote): tools/list failed for 1 tool source(s), succeeded for 0 tool source(s) " +
|
||||
"{\"errors\":[{\"name\":\"send_email\",\"type\":\"mcp\",\"error\":{\"code\":\"CONSENT_REQUIRED\",\"message\":\"https://login.example.com/consent?data=abc\"}}]}";
|
||||
|
||||
// Act
|
||||
var parsed = ToolboxConsentParser.TryParseConsentRequired("auth-paths-toolbox", Message, out var consents);
|
||||
|
||||
// Assert
|
||||
Assert.True(parsed);
|
||||
var consent = Assert.Single(consents);
|
||||
Assert.Equal("auth-paths-toolbox", consent.ToolboxName);
|
||||
Assert.Equal("send_email", consent.ToolName);
|
||||
Assert.Equal("https://login.example.com/consent?data=abc", consent.ConsentUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseConsentRequired_MultipleConsentErrors_ReturnsAll()
|
||||
{
|
||||
// Arrange
|
||||
const string Message =
|
||||
"tools/list failed " +
|
||||
"{\"errors\":[" +
|
||||
"{\"name\":\"send_email\",\"type\":\"mcp\",\"error\":{\"code\":\"CONSENT_REQUIRED\",\"message\":\"https://consent/a\"}}," +
|
||||
"{\"name\":\"read_calendar\",\"type\":\"mcp\",\"error\":{\"code\":\"CONSENT_REQUIRED\",\"message\":\"https://consent/b\"}}]}";
|
||||
|
||||
// Act
|
||||
var parsed = ToolboxConsentParser.TryParseConsentRequired("toolbox", Message, out var consents);
|
||||
|
||||
// Assert
|
||||
Assert.True(parsed);
|
||||
Assert.Equal(2, consents.Count);
|
||||
Assert.Equal("send_email", consents[0].ToolName);
|
||||
Assert.Equal("https://consent/a", consents[0].ConsentUrl);
|
||||
Assert.Equal("read_calendar", consents[1].ToolName);
|
||||
Assert.Equal("https://consent/b", consents[1].ConsentUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseConsentRequired_MixedWithNonConsentError_ReturnsFalse()
|
||||
{
|
||||
// Arrange: when any source fails for a non-consent reason, consent alone cannot make
|
||||
// enumeration succeed, so the caller must treat the failure as a hard error.
|
||||
const string Message =
|
||||
"tools/list failed " +
|
||||
"{\"errors\":[" +
|
||||
"{\"name\":\"send_email\",\"type\":\"mcp\",\"error\":{\"code\":\"CONSENT_REQUIRED\",\"message\":\"https://consent/a\"}}," +
|
||||
"{\"name\":\"broken\",\"type\":\"mcp\",\"error\":{\"code\":\"INTERNAL_ERROR\",\"message\":\"boom\"}}]}";
|
||||
|
||||
// Act
|
||||
var parsed = ToolboxConsentParser.TryParseConsentRequired("toolbox", Message, out var consents);
|
||||
|
||||
// Assert
|
||||
Assert.False(parsed);
|
||||
Assert.Empty(consents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseConsentRequired_NoJsonPayload_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var parsed = ToolboxConsentParser.TryParseConsentRequired("toolbox", "connection refused", out var consents);
|
||||
|
||||
// Assert
|
||||
Assert.False(parsed);
|
||||
Assert.Empty(consents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseConsentRequired_CodePresentButMalformedJson_ReturnsFalse()
|
||||
{
|
||||
// Arrange: the marker code is present but the embedded JSON is not parseable.
|
||||
const string Message = "tools/list failed CONSENT_REQUIRED {\"errors\":[ not valid json";
|
||||
|
||||
// Act
|
||||
var parsed = ToolboxConsentParser.TryParseConsentRequired("toolbox", Message, out var consents);
|
||||
|
||||
// Assert
|
||||
Assert.False(parsed);
|
||||
Assert.Empty(consents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseConsentRequired_ConsentErrorWithoutUrl_ReturnsFalse()
|
||||
{
|
||||
// Arrange: a CONSENT_REQUIRED error with no message means there is no URL to present.
|
||||
const string Message =
|
||||
"tools/list failed " +
|
||||
"{\"errors\":[{\"name\":\"send_email\",\"type\":\"mcp\",\"error\":{\"code\":\"CONSENT_REQUIRED\",\"message\":\"\"}}]}";
|
||||
|
||||
// Act
|
||||
var parsed = ToolboxConsentParser.TryParseConsentRequired("toolbox", Message, out var consents);
|
||||
|
||||
// Assert
|
||||
Assert.False(parsed);
|
||||
Assert.Empty(consents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseConsentRequired_MissingToolName_FallsBackToToolboxName()
|
||||
{
|
||||
// Arrange: when the failing source has no name, the toolbox name is used as the tool name.
|
||||
const string Message =
|
||||
"tools/list failed " +
|
||||
"{\"errors\":[{\"type\":\"mcp\",\"error\":{\"code\":\"CONSENT_REQUIRED\",\"message\":\"https://consent/x\"}}]}";
|
||||
|
||||
// Act
|
||||
var parsed = ToolboxConsentParser.TryParseConsentRequired("my-toolbox", Message, out var consents);
|
||||
|
||||
// Assert
|
||||
Assert.True(parsed);
|
||||
var consent = Assert.Single(consents);
|
||||
Assert.Equal("my-toolbox", consent.ToolName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public void TryParseConsentRequired_NullOrEmptyMessage_ReturnsFalse(string? message)
|
||||
{
|
||||
// Act
|
||||
var parsed = ToolboxConsentParser.TryParseConsentRequired("toolbox", message, out var consents);
|
||||
|
||||
// Assert
|
||||
Assert.False(parsed);
|
||||
Assert.Empty(consents);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user