mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
a3a9147e61
* Rename AgentThread to AgentSession * Add more renames * Update readme files * Revert nullable variable change and further fixes. * Revert change in header name * Fix some comments and tests * Update changelog. * Address PR feedback. * Fixing code review comments. * Fix new errors after merging latest code.
1033 lines
39 KiB
C#
1033 lines
39 KiB
C#
// Copyright (c) Microsoft. All rights reserved.
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.AI;
|
|
using Moq;
|
|
using Moq.Protected;
|
|
|
|
namespace Microsoft.Agents.AI.UnitTests;
|
|
|
|
/// <summary>
|
|
/// Unit tests for the <see cref="AnonymousDelegatingAIAgent"/> class.
|
|
/// </summary>
|
|
public class AnonymousDelegatingAIAgentTests
|
|
{
|
|
private readonly Mock<AIAgent> _innerAgentMock;
|
|
private readonly List<ChatMessage> _testMessages;
|
|
private readonly AgentSession _testSession;
|
|
private readonly AgentRunOptions _testOptions;
|
|
private readonly AgentResponse _testResponse;
|
|
private readonly AgentResponseUpdate[] _testStreamingResponses;
|
|
|
|
public AnonymousDelegatingAIAgentTests()
|
|
{
|
|
this._innerAgentMock = new Mock<AIAgent>();
|
|
this._testMessages = [new ChatMessage(ChatRole.User, "Test message")];
|
|
this._testSession = new Mock<AgentSession>().Object;
|
|
this._testOptions = new AgentRunOptions();
|
|
this._testResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Test response")]);
|
|
this._testStreamingResponses = [
|
|
new AgentResponseUpdate(ChatRole.Assistant, "Response 1"),
|
|
new AgentResponseUpdate(ChatRole.Assistant, "Response 2")
|
|
];
|
|
|
|
this._innerAgentMock
|
|
.Protected()
|
|
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
|
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
|
ItExpr.IsAny<AgentSession?>(),
|
|
ItExpr.IsAny<AgentRunOptions?>(),
|
|
ItExpr.IsAny<CancellationToken>())
|
|
.ReturnsAsync(this._testResponse);
|
|
|
|
this._innerAgentMock
|
|
.Protected()
|
|
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
|
|
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
|
ItExpr.IsAny<AgentSession?>(),
|
|
ItExpr.IsAny<AgentRunOptions?>(),
|
|
ItExpr.IsAny<CancellationToken>())
|
|
.Returns(ToAsyncEnumerableAsync(this._testStreamingResponses));
|
|
}
|
|
|
|
#region Constructor Tests
|
|
|
|
/// <summary>
|
|
/// Verify that constructor throws ArgumentNullException when innerAgent is null.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Constructor_WithNullInnerAgent_ThrowsArgumentNullException()
|
|
{
|
|
// Act & Assert
|
|
Assert.Throws<ArgumentNullException>("innerAgent", () =>
|
|
new AnonymousDelegatingAIAgent(null!, (_, _, _, _, _) => Task.CompletedTask));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that constructor throws ArgumentNullException when sharedFunc is null.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Constructor_WithNullSharedFunc_ThrowsArgumentNullException()
|
|
{
|
|
// Act & Assert
|
|
Assert.Throws<ArgumentNullException>("sharedFunc", () =>
|
|
new AnonymousDelegatingAIAgent(this._innerAgentMock.Object, null!));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that constructor throws ArgumentNullException when both delegates are null.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Constructor_WithBothDelegatesNull_ThrowsArgumentNullException()
|
|
{
|
|
// Act & Assert
|
|
var exception = Assert.Throws<ArgumentNullException>(() =>
|
|
new AnonymousDelegatingAIAgent(this._innerAgentMock.Object, null, null));
|
|
|
|
Assert.Contains("runFunc", exception.Message);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that constructor succeeds with valid sharedFunc.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Constructor_WithValidSharedFunc_Succeeds()
|
|
{
|
|
// Act
|
|
var agent = new AnonymousDelegatingAIAgent(this._innerAgentMock.Object, (_, _, _, _, _) => Task.CompletedTask);
|
|
|
|
// Assert
|
|
Assert.NotNull(agent);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that constructor succeeds with valid runFunc only.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Constructor_WithValidRunFunc_Succeeds()
|
|
{
|
|
// Act
|
|
var agent = new AnonymousDelegatingAIAgent(
|
|
this._innerAgentMock.Object,
|
|
(_, _, _, _, _) => Task.FromResult(this._testResponse),
|
|
null);
|
|
|
|
// Assert
|
|
Assert.NotNull(agent);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that constructor succeeds with valid runStreamingFunc only.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Constructor_WithValidRunStreamingFunc_Succeeds()
|
|
{
|
|
// Act
|
|
var agent = new AnonymousDelegatingAIAgent(
|
|
this._innerAgentMock.Object,
|
|
null,
|
|
(_, _, _, _, _) => ToAsyncEnumerableAsync(this._testStreamingResponses));
|
|
|
|
// Assert
|
|
Assert.NotNull(agent);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that constructor succeeds with both runFunc and runStreamingFunc.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Constructor_WithBothRunAndStreamingFunc_Succeeds()
|
|
{
|
|
// Act
|
|
var agent = new AnonymousDelegatingAIAgent(
|
|
this._innerAgentMock.Object,
|
|
(_, _, _, _, _) => Task.FromResult(this._testResponse),
|
|
(_, _, _, _, _) => ToAsyncEnumerableAsync(this._testStreamingResponses));
|
|
|
|
// Assert
|
|
Assert.NotNull(agent);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Shared Function Tests
|
|
|
|
/// <summary>
|
|
/// Verify that shared function receives correct context and calls inner agent.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunAsync_WithSharedFunc_ContextPropagatedAsync()
|
|
{
|
|
// Arrange
|
|
IEnumerable<ChatMessage>? capturedMessages = null;
|
|
AgentSession? capturedSession = null;
|
|
AgentRunOptions? capturedOptions = null;
|
|
CancellationToken capturedCancellationToken = default;
|
|
var expectedCancellationToken = new CancellationToken(true);
|
|
|
|
var agent = new AnonymousDelegatingAIAgent(this._innerAgentMock.Object,
|
|
async (messages, session, options, next, cancellationToken) =>
|
|
{
|
|
capturedMessages = messages;
|
|
capturedSession = session;
|
|
capturedOptions = options;
|
|
capturedCancellationToken = cancellationToken;
|
|
await next(messages, session, options, cancellationToken);
|
|
});
|
|
|
|
// Act
|
|
await agent.RunAsync(this._testMessages, this._testSession, this._testOptions, expectedCancellationToken);
|
|
|
|
// Assert
|
|
Assert.Same(this._testMessages, capturedMessages);
|
|
Assert.Same(this._testSession, capturedSession);
|
|
Assert.Same(this._testOptions, capturedOptions);
|
|
Assert.Equal(expectedCancellationToken, capturedCancellationToken);
|
|
|
|
this._innerAgentMock
|
|
.Protected()
|
|
.Verify<Task<AgentResponse>>("RunCoreAsync",
|
|
Times.Once(),
|
|
ItExpr.Is<IEnumerable<ChatMessage>>(m => m == this._testMessages),
|
|
ItExpr.Is<AgentSession?>(t => t == this._testSession),
|
|
ItExpr.Is<AgentRunOptions?>(o => o == this._testOptions),
|
|
ItExpr.Is<CancellationToken>(ct => ct == expectedCancellationToken));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that shared function works for both RunAsync and RunStreamingAsync.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task SharedFunc_WorksForBothRunAndStreamingAsync()
|
|
{
|
|
// Arrange
|
|
var callCount = 0;
|
|
var agent = new AnonymousDelegatingAIAgent(this._innerAgentMock.Object,
|
|
async (messages, session, options, next, cancellationToken) =>
|
|
{
|
|
callCount++;
|
|
await next(messages, session, options, cancellationToken);
|
|
});
|
|
|
|
// Act
|
|
await agent.RunAsync(this._testMessages, this._testSession, this._testOptions);
|
|
var streamingResults = await agent.RunStreamingAsync(this._testMessages, this._testSession, this._testOptions).ToListAsync();
|
|
|
|
// Assert
|
|
Assert.Equal(2, callCount);
|
|
Assert.NotNull(streamingResults);
|
|
Assert.Equal(this._testStreamingResponses.Length, streamingResults.Count);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Separate Delegate Tests
|
|
|
|
/// <summary>
|
|
/// Verify that RunAsync with runFunc only uses the runFunc.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunAsync_WithRunFuncOnly_UsesRunFuncAsync()
|
|
{
|
|
// Arrange
|
|
var runFuncCalled = false;
|
|
var agent = new AnonymousDelegatingAIAgent(
|
|
this._innerAgentMock.Object,
|
|
(messages, session, options, innerAgent, cancellationToken) =>
|
|
{
|
|
runFuncCalled = true;
|
|
return innerAgent.RunAsync(messages, session, options, cancellationToken);
|
|
},
|
|
null);
|
|
|
|
// Act
|
|
var result = await agent.RunAsync(this._testMessages, this._testSession, this._testOptions);
|
|
|
|
// Assert
|
|
Assert.True(runFuncCalled);
|
|
Assert.Same(this._testResponse, result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that RunStreamingAsync with runFunc only converts from runFunc.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunStreamingAsync_WithRunFuncOnly_ConvertsFromRunFuncAsync()
|
|
{
|
|
// Arrange
|
|
var runFuncCalled = false;
|
|
var agent = new AnonymousDelegatingAIAgent(
|
|
this._innerAgentMock.Object,
|
|
(messages, session, options, innerAgent, cancellationToken) =>
|
|
{
|
|
runFuncCalled = true;
|
|
return innerAgent.RunAsync(messages, session, options, cancellationToken);
|
|
},
|
|
null);
|
|
|
|
// Act
|
|
var results = await agent.RunStreamingAsync(this._testMessages, this._testSession, this._testOptions).ToListAsync();
|
|
|
|
// Assert
|
|
Assert.True(runFuncCalled);
|
|
Assert.NotEmpty(results);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that RunAsync with runStreamingFunc only converts from runStreamingFunc.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunAsync_WithStreamingFuncOnly_ConvertsFromStreamingFuncAsync()
|
|
{
|
|
// Arrange
|
|
var streamingFuncCalled = false;
|
|
var agent = new AnonymousDelegatingAIAgent(
|
|
this._innerAgentMock.Object,
|
|
null,
|
|
(messages, session, options, innerAgent, cancellationToken) =>
|
|
{
|
|
streamingFuncCalled = true;
|
|
return innerAgent.RunStreamingAsync(messages, session, options, cancellationToken);
|
|
});
|
|
|
|
// Act
|
|
var result = await agent.RunAsync(this._testMessages, this._testSession, this._testOptions);
|
|
|
|
// Assert
|
|
Assert.True(streamingFuncCalled);
|
|
Assert.NotNull(result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that RunStreamingAsync with runStreamingFunc only uses the runStreamingFunc.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunStreamingAsync_WithStreamingFuncOnly_UsesStreamingFuncAsync()
|
|
{
|
|
// Arrange
|
|
var streamingFuncCalled = false;
|
|
var agent = new AnonymousDelegatingAIAgent(
|
|
this._innerAgentMock.Object,
|
|
null,
|
|
(messages, session, options, innerAgent, cancellationToken) =>
|
|
{
|
|
streamingFuncCalled = true;
|
|
return innerAgent.RunStreamingAsync(messages, session, options, cancellationToken);
|
|
});
|
|
|
|
// Act
|
|
var results = await agent.RunStreamingAsync(this._testMessages, this._testSession, this._testOptions).ToListAsync();
|
|
|
|
// Assert
|
|
Assert.True(streamingFuncCalled);
|
|
Assert.Equal(this._testStreamingResponses.Length, results.Count);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that when both delegates are provided, each uses its respective implementation.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task BothDelegates_EachUsesRespectiveImplementationAsync()
|
|
{
|
|
// Arrange
|
|
var runFuncCalled = false;
|
|
var streamingFuncCalled = false;
|
|
|
|
var agent = new AnonymousDelegatingAIAgent(
|
|
this._innerAgentMock.Object,
|
|
(messages, session, options, innerAgent, cancellationToken) =>
|
|
{
|
|
runFuncCalled = true;
|
|
return innerAgent.RunAsync(messages, session, options, cancellationToken);
|
|
},
|
|
(messages, session, options, innerAgent, cancellationToken) =>
|
|
{
|
|
streamingFuncCalled = true;
|
|
return innerAgent.RunStreamingAsync(messages, session, options, cancellationToken);
|
|
});
|
|
|
|
// Act
|
|
await agent.RunAsync(this._testMessages, this._testSession, this._testOptions);
|
|
await agent.RunStreamingAsync(this._testMessages, this._testSession, this._testOptions).ToListAsync();
|
|
|
|
// Assert
|
|
Assert.True(runFuncCalled);
|
|
Assert.True(streamingFuncCalled);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Error Handling Tests
|
|
|
|
/// <summary>
|
|
/// Verify that exceptions from shared function are propagated.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task SharedFunc_ThrowsException_PropagatesExceptionAsync()
|
|
{
|
|
// Arrange
|
|
var expectedException = new InvalidOperationException("Test exception");
|
|
var agent = new AnonymousDelegatingAIAgent(this._innerAgentMock.Object,
|
|
(_, _, _, _, _) => throw expectedException);
|
|
|
|
// Act & Assert
|
|
var actualException = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => agent.RunAsync(this._testMessages, this._testSession, this._testOptions));
|
|
|
|
Assert.Same(expectedException, actualException);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that exceptions from runFunc are propagated.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunFunc_ThrowsException_PropagatesExceptionAsync()
|
|
{
|
|
// Arrange
|
|
var expectedException = new InvalidOperationException("Test exception");
|
|
var agent = new AnonymousDelegatingAIAgent(
|
|
this._innerAgentMock.Object,
|
|
(_, _, _, _, _) => throw expectedException,
|
|
null);
|
|
|
|
// Act & Assert
|
|
var actualException = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => agent.RunAsync(this._testMessages, this._testSession, this._testOptions));
|
|
|
|
Assert.Same(expectedException, actualException);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that exceptions from runStreamingFunc are propagated.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task StreamingFunc_ThrowsException_PropagatesExceptionAsync()
|
|
{
|
|
// Arrange
|
|
var expectedException = new InvalidOperationException("Test exception");
|
|
var agent = new AnonymousDelegatingAIAgent(
|
|
this._innerAgentMock.Object,
|
|
null,
|
|
(_, _, _, _, _) => throw expectedException);
|
|
|
|
// Act & Assert
|
|
var actualException = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
|
{
|
|
await foreach (var _ in agent.RunStreamingAsync(this._testMessages, this._testSession, this._testOptions))
|
|
{
|
|
// Should throw before yielding any items
|
|
}
|
|
});
|
|
|
|
Assert.Same(expectedException, actualException);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that shared function that doesn't call inner agent throws InvalidOperationException.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task SharedFunc_DoesNotCallInner_ThrowsInvalidOperationAsync()
|
|
{
|
|
// Arrange
|
|
var agent = new AnonymousDelegatingAIAgent(this._innerAgentMock.Object,
|
|
(_, _, _, _, _) => Task.CompletedTask); // Doesn't call next
|
|
|
|
// Act & Assert
|
|
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => agent.RunAsync(this._testMessages, this._testSession, this._testOptions));
|
|
|
|
Assert.Contains("without producing an AgentResponse", exception.Message);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region AsyncLocal Context Tests
|
|
|
|
/// <summary>
|
|
/// Verify that AsyncLocal context is maintained across delegate boundaries.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AsyncLocalContext_MaintainedAcrossDelegatesAsync()
|
|
{
|
|
// Arrange
|
|
var asyncLocal = new AsyncLocal<int>();
|
|
var capturedValue = 0;
|
|
|
|
var agent = new AnonymousDelegatingAIAgent(this._innerAgentMock.Object,
|
|
async (messages, session, options, next, cancellationToken) =>
|
|
{
|
|
asyncLocal.Value = 42;
|
|
await next(messages, session, options, cancellationToken);
|
|
capturedValue = asyncLocal.Value;
|
|
});
|
|
|
|
this._innerAgentMock
|
|
.Protected()
|
|
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
|
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
|
ItExpr.IsAny<AgentSession?>(),
|
|
ItExpr.IsAny<AgentRunOptions?>(),
|
|
ItExpr.IsAny<CancellationToken>())
|
|
.Returns(() =>
|
|
{
|
|
// Verify AsyncLocal value is available in inner agent call
|
|
Assert.Equal(42, asyncLocal.Value);
|
|
return Task.FromResult(this._testResponse);
|
|
});
|
|
|
|
// Act
|
|
Assert.Equal(0, asyncLocal.Value); // Initial value
|
|
await agent.RunAsync(this._testMessages, this._testSession, this._testOptions);
|
|
|
|
// Assert
|
|
Assert.Equal(0, asyncLocal.Value); // Should be reset after call
|
|
Assert.Equal(42, capturedValue); // But was maintained during call
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Multiple Middleware Chaining Tests
|
|
|
|
/// <summary>
|
|
/// Verify that multiple middleware execute in correct order (outer-to-inner, then inner-to-outer).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task MultipleMiddleware_ExecuteInCorrectOrderAsync()
|
|
{
|
|
// Arrange
|
|
var executionOrder = new List<string>();
|
|
|
|
var outerAgent = new AnonymousDelegatingAIAgent(this._innerAgentMock.Object,
|
|
async (messages, session, options, next, cancellationToken) =>
|
|
{
|
|
executionOrder.Add("Outer-Pre");
|
|
await next(messages, session, options, cancellationToken);
|
|
executionOrder.Add("Outer-Post");
|
|
});
|
|
|
|
var middleAgent = new AnonymousDelegatingAIAgent(outerAgent,
|
|
async (messages, session, options, next, cancellationToken) =>
|
|
{
|
|
executionOrder.Add("Middle-Pre");
|
|
await next(messages, session, options, cancellationToken);
|
|
executionOrder.Add("Middle-Post");
|
|
});
|
|
|
|
var innerAgent = new AnonymousDelegatingAIAgent(middleAgent,
|
|
async (messages, session, options, next, cancellationToken) =>
|
|
{
|
|
executionOrder.Add("Inner-Pre");
|
|
await next(messages, session, options, cancellationToken);
|
|
executionOrder.Add("Inner-Post");
|
|
});
|
|
|
|
// Act
|
|
await innerAgent.RunAsync(this._testMessages, this._testSession, this._testOptions);
|
|
|
|
// Assert
|
|
var expectedOrder = new[] { "Inner-Pre", "Middle-Pre", "Outer-Pre", "Outer-Post", "Middle-Post", "Inner-Post" };
|
|
Assert.Equal(expectedOrder, executionOrder);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that multiple middleware with separate delegates execute in correct order.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task MultipleMiddleware_SeparateDelegates_ExecuteInCorrectOrderAsync()
|
|
{
|
|
// Arrange
|
|
var executionOrder = new List<string>();
|
|
|
|
var outerAgent = new AnonymousDelegatingAIAgent(this._innerAgentMock.Object,
|
|
(messages, session, options, innerAgent, cancellationToken) =>
|
|
{
|
|
executionOrder.Add("Outer-Run");
|
|
return innerAgent.RunAsync(messages, session, options, cancellationToken);
|
|
},
|
|
(messages, session, options, innerAgent, cancellationToken) =>
|
|
{
|
|
executionOrder.Add("Outer-Streaming");
|
|
return innerAgent.RunStreamingAsync(messages, session, options, cancellationToken);
|
|
});
|
|
|
|
var middleAgent = new AnonymousDelegatingAIAgent(outerAgent,
|
|
(messages, session, options, innerAgent, cancellationToken) =>
|
|
{
|
|
executionOrder.Add("Middle-Run");
|
|
return innerAgent.RunAsync(messages, session, options, cancellationToken);
|
|
},
|
|
(messages, session, options, innerAgent, cancellationToken) =>
|
|
{
|
|
executionOrder.Add("Middle-Streaming");
|
|
return innerAgent.RunStreamingAsync(messages, session, options, cancellationToken);
|
|
});
|
|
|
|
// Act
|
|
await middleAgent.RunAsync(this._testMessages, this._testSession, this._testOptions);
|
|
await middleAgent.RunStreamingAsync(this._testMessages, this._testSession, this._testOptions).ToListAsync();
|
|
|
|
// Assert
|
|
Assert.Contains("Middle-Run", executionOrder);
|
|
Assert.Contains("Outer-Run", executionOrder);
|
|
Assert.Contains("Middle-Streaming", executionOrder);
|
|
Assert.Contains("Outer-Streaming", executionOrder);
|
|
|
|
var runIndex = executionOrder.IndexOf("Middle-Run");
|
|
var outerRunIndex = executionOrder.IndexOf("Outer-Run");
|
|
var streamingIndex = executionOrder.IndexOf("Middle-Streaming");
|
|
var outerStreamingIndex = executionOrder.IndexOf("Outer-Streaming");
|
|
|
|
Assert.True(runIndex < outerRunIndex);
|
|
Assert.True(streamingIndex < outerStreamingIndex);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that middleware can capture and modify parameters during execution.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task MultipleMiddleware_ContextModification_PropagatedAsync()
|
|
{
|
|
// Arrange
|
|
var capturedOptions = new List<AgentRunOptions?>();
|
|
var executionOrder = new List<string>();
|
|
|
|
var outerAgent = new AnonymousDelegatingAIAgent(this._innerAgentMock.Object,
|
|
async (messages, session, options, next, cancellationToken) =>
|
|
{
|
|
executionOrder.Add("Outer-Pre");
|
|
await next(messages, session, options, cancellationToken);
|
|
executionOrder.Add("Outer-Post");
|
|
});
|
|
|
|
var innerAgent = new AnonymousDelegatingAIAgent(outerAgent,
|
|
async (messages, session, options, next, cancellationToken) =>
|
|
{
|
|
executionOrder.Add("Inner-Pre");
|
|
capturedOptions.Add(options);
|
|
await next(messages, session, options, cancellationToken);
|
|
executionOrder.Add("Inner-Post");
|
|
});
|
|
|
|
// Act
|
|
await innerAgent.RunAsync(this._testMessages, this._testSession, this._testOptions);
|
|
|
|
// Assert
|
|
Assert.Single(capturedOptions);
|
|
Assert.Same(this._testOptions, capturedOptions[0]); // Inner middleware sees original options
|
|
var expectedOrder = new[] { "Inner-Pre", "Outer-Pre", "Outer-Post", "Inner-Post" };
|
|
Assert.Equal(expectedOrder, executionOrder);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Error Handling in Chains Tests
|
|
|
|
/// <summary>
|
|
/// Verify that exceptions in middleware chains are properly propagated.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task MultipleMiddleware_ExceptionInMiddle_PropagatesAsync()
|
|
{
|
|
// Arrange
|
|
var expectedException = new InvalidOperationException("Middle middleware error");
|
|
var outerExecuted = false;
|
|
var innerExecuted = false;
|
|
|
|
var outerAgent = new AnonymousDelegatingAIAgent(this._innerAgentMock.Object,
|
|
async (messages, session, options, next, cancellationToken) =>
|
|
{
|
|
outerExecuted = true;
|
|
await next(messages, session, options, cancellationToken);
|
|
});
|
|
|
|
var middleAgent = new AnonymousDelegatingAIAgent(outerAgent,
|
|
(_, _, _, _, _) => throw expectedException);
|
|
|
|
var innerAgent = new AnonymousDelegatingAIAgent(middleAgent,
|
|
async (messages, session, options, next, cancellationToken) =>
|
|
{
|
|
innerExecuted = true;
|
|
await next(messages, session, options, cancellationToken);
|
|
});
|
|
|
|
// Act & Assert
|
|
var actualException = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => innerAgent.RunAsync(this._testMessages, this._testSession, this._testOptions));
|
|
|
|
Assert.Same(expectedException, actualException);
|
|
Assert.True(innerExecuted); // Inner middleware should execute
|
|
Assert.False(outerExecuted); // Outer middleware should not execute due to exception
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that exceptions in streaming middleware chains are properly propagated.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task MultipleMiddleware_ExceptionInStreaming_PropagatesAsync()
|
|
{
|
|
// Arrange
|
|
var expectedException = new InvalidOperationException("Streaming middleware error");
|
|
|
|
var outerAgent = new AnonymousDelegatingAIAgent(this._innerAgentMock.Object,
|
|
null,
|
|
(_, _, _, _, _) => throw expectedException);
|
|
|
|
var innerAgent = new AnonymousDelegatingAIAgent(outerAgent,
|
|
null,
|
|
(messages, session, options, innerAgent, cancellationToken) =>
|
|
innerAgent.RunStreamingAsync(messages, session, options, cancellationToken));
|
|
|
|
// Act & Assert
|
|
var actualException = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
|
{
|
|
await foreach (var _ in innerAgent.RunStreamingAsync(this._testMessages, this._testSession, this._testOptions))
|
|
{
|
|
// Should throw before yielding any items
|
|
}
|
|
});
|
|
|
|
Assert.Same(expectedException, actualException);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Multiple Middleware Chaining Tests
|
|
|
|
/// <summary>
|
|
/// Verify that multiple middleware using AIAgentBuilder.Use() execute in correct order.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AIAgentBuilder_Use_MultipleMiddleware_ExecutesInCorrectOrderAsync()
|
|
{
|
|
// Arrange
|
|
var executionOrder = new List<string>();
|
|
|
|
var agent = new AIAgentBuilder(this._innerAgentMock.Object)
|
|
.Use(async (messages, session, options, next, cancellationToken) =>
|
|
{
|
|
executionOrder.Add("First-Pre");
|
|
await next(messages, session, options, cancellationToken);
|
|
executionOrder.Add("First-Post");
|
|
})
|
|
.Use(async (messages, session, options, next, cancellationToken) =>
|
|
{
|
|
executionOrder.Add("Second-Pre");
|
|
await next(messages, session, options, cancellationToken);
|
|
executionOrder.Add("Second-Post");
|
|
})
|
|
.Build();
|
|
|
|
// Act
|
|
await agent.RunAsync(this._testMessages, this._testSession, this._testOptions);
|
|
|
|
// Assert
|
|
var expectedOrder = new[] { "First-Pre", "Second-Pre", "Second-Post", "First-Post" };
|
|
Assert.Equal(expectedOrder, executionOrder);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that multiple middleware with separate run/streaming delegates execute correctly.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AIAgentBuilder_Use_MultipleMiddlewareWithSeparateDelegates_ExecutesCorrectlyAsync()
|
|
{
|
|
// Arrange
|
|
var runExecutionOrder = new List<string>();
|
|
var streamingExecutionOrder = new List<string>();
|
|
|
|
static async IAsyncEnumerable<AgentResponseUpdate> FirstStreamingMiddlewareAsync(
|
|
IEnumerable<ChatMessage> messages, AgentSession? session, AgentRunOptions? options, AIAgent innerAgent,
|
|
[EnumeratorCancellation] CancellationToken cancellationToken,
|
|
List<string> executionOrder)
|
|
{
|
|
executionOrder.Add("First-Streaming-Pre");
|
|
await foreach (var update in innerAgent.RunStreamingAsync(messages, session, options, cancellationToken))
|
|
{
|
|
yield return update;
|
|
}
|
|
executionOrder.Add("First-Streaming-Post");
|
|
}
|
|
|
|
static async IAsyncEnumerable<AgentResponseUpdate> SecondStreamingMiddlewareAsync(
|
|
IEnumerable<ChatMessage> messages, AgentSession? session, AgentRunOptions? options, AIAgent innerAgent,
|
|
[EnumeratorCancellation] CancellationToken cancellationToken,
|
|
List<string> executionOrder)
|
|
{
|
|
executionOrder.Add("Second-Streaming-Pre");
|
|
await foreach (var update in innerAgent.RunStreamingAsync(messages, session, options, cancellationToken))
|
|
{
|
|
yield return update;
|
|
}
|
|
executionOrder.Add("Second-Streaming-Post");
|
|
}
|
|
|
|
var agent = new AIAgentBuilder(this._innerAgentMock.Object)
|
|
.Use(
|
|
async (messages, session, options, innerAgent, cancellationToken) =>
|
|
{
|
|
runExecutionOrder.Add("First-Run-Pre");
|
|
var result = await innerAgent.RunAsync(messages, session, options, cancellationToken);
|
|
runExecutionOrder.Add("First-Run-Post");
|
|
return result;
|
|
},
|
|
(messages, session, options, innerAgent, cancellationToken) =>
|
|
FirstStreamingMiddlewareAsync(messages, session, options, innerAgent, cancellationToken, streamingExecutionOrder))
|
|
.Use(
|
|
async (messages, session, options, innerAgent, cancellationToken) =>
|
|
{
|
|
runExecutionOrder.Add("Second-Run-Pre");
|
|
var result = await innerAgent.RunAsync(messages, session, options, cancellationToken);
|
|
runExecutionOrder.Add("Second-Run-Post");
|
|
return result;
|
|
},
|
|
(messages, session, options, innerAgent, cancellationToken) =>
|
|
SecondStreamingMiddlewareAsync(messages, session, options, innerAgent, cancellationToken, streamingExecutionOrder))
|
|
.Build();
|
|
|
|
// Act
|
|
await agent.RunAsync(this._testMessages, this._testSession, this._testOptions);
|
|
await agent.RunStreamingAsync(this._testMessages, this._testSession, this._testOptions).ToListAsync();
|
|
|
|
// Assert
|
|
var expectedRunOrder = new[] { "First-Run-Pre", "Second-Run-Pre", "Second-Run-Post", "First-Run-Post" };
|
|
var expectedStreamingOrder = new[] { "First-Streaming-Pre", "Second-Streaming-Pre", "Second-Streaming-Post", "First-Streaming-Post" };
|
|
|
|
Assert.Equal(expectedRunOrder, runExecutionOrder);
|
|
Assert.Equal(expectedStreamingOrder, streamingExecutionOrder);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that middleware can modify messages and options before passing to next middleware.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AIAgentBuilder_Use_MiddlewareModifiesContext_ChangesPropagateAsync()
|
|
{
|
|
// Arrange
|
|
IEnumerable<ChatMessage>? capturedMessages = null;
|
|
AgentRunOptions? capturedOptions = null;
|
|
|
|
var agent = new AIAgentBuilder(this._innerAgentMock.Object)
|
|
.Use(async (messages, session, options, next, cancellationToken) =>
|
|
{
|
|
// Modify messages and options
|
|
var modifiedMessages = messages.Concat([new ChatMessage(ChatRole.System, "Added by first middleware")]);
|
|
var modifiedOptions = new AgentRunOptions();
|
|
await next(modifiedMessages, session, modifiedOptions, cancellationToken);
|
|
})
|
|
.Use(async (messages, session, options, next, cancellationToken) =>
|
|
{
|
|
// Capture what the second middleware receives
|
|
capturedMessages = messages;
|
|
capturedOptions = options;
|
|
await next(messages, session, options, cancellationToken);
|
|
})
|
|
.Build();
|
|
|
|
// Act
|
|
await agent.RunAsync(this._testMessages, this._testSession, this._testOptions);
|
|
|
|
// Assert
|
|
Assert.NotNull(capturedMessages);
|
|
Assert.NotNull(capturedOptions);
|
|
Assert.Equal(2, capturedMessages.Count()); // Original + added message
|
|
Assert.Contains(capturedMessages, m => m.Text == "Added by first middleware");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Error Handling in Chains Tests
|
|
|
|
/// <summary>
|
|
/// Verify that exceptions in middleware chains are properly propagated.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AIAgentBuilder_Use_ExceptionInMiddlewareChain_PropagatesCorrectlyAsync()
|
|
{
|
|
// Arrange
|
|
var expectedException = new InvalidOperationException("Test exception from middleware");
|
|
var executionOrder = new List<string>();
|
|
|
|
var agent = new AIAgentBuilder(this._innerAgentMock.Object)
|
|
.Use(async (messages, session, options, next, cancellationToken) =>
|
|
{
|
|
executionOrder.Add("First-Pre");
|
|
try
|
|
{
|
|
await next(messages, session, options, cancellationToken);
|
|
executionOrder.Add("First-Post-Success");
|
|
}
|
|
catch
|
|
{
|
|
executionOrder.Add("First-Post-Exception");
|
|
throw;
|
|
}
|
|
})
|
|
.Use(async (messages, session, options, next, cancellationToken) =>
|
|
{
|
|
executionOrder.Add("Second-Pre");
|
|
throw expectedException;
|
|
})
|
|
.Build();
|
|
|
|
// Act & Assert
|
|
var actualException = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => agent.RunAsync(this._testMessages, this._testSession, this._testOptions));
|
|
|
|
Assert.Same(expectedException, actualException);
|
|
var expectedOrder = new[] { "First-Pre", "Second-Pre", "First-Post-Exception" };
|
|
Assert.Equal(expectedOrder, executionOrder);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that middleware can handle and recover from exceptions in the chain.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AIAgentBuilder_Use_MiddlewareHandlesException_RecoveryWorksAsync()
|
|
{
|
|
// Arrange
|
|
var executionOrder = new List<string>();
|
|
var fallbackResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Fallback response")]);
|
|
|
|
var agent = new AIAgentBuilder(this._innerAgentMock.Object)
|
|
.Use(
|
|
async (messages, session, options, innerAgent, cancellationToken) =>
|
|
{
|
|
executionOrder.Add("Handler-Pre");
|
|
try
|
|
{
|
|
return await innerAgent.RunAsync(messages, session, options, cancellationToken);
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
executionOrder.Add("Handler-Caught-Exception");
|
|
return fallbackResponse;
|
|
}
|
|
},
|
|
null)
|
|
.Use(async (messages, session, options, next, cancellationToken) =>
|
|
{
|
|
executionOrder.Add("Throwing-Pre");
|
|
throw new InvalidOperationException("Simulated error");
|
|
})
|
|
.Build();
|
|
|
|
// Act
|
|
var result = await agent.RunAsync(this._testMessages, this._testSession, this._testOptions);
|
|
|
|
// Assert
|
|
Assert.Same(fallbackResponse, result);
|
|
var expectedOrder = new[] { "Handler-Pre", "Throwing-Pre", "Handler-Caught-Exception" };
|
|
Assert.Equal(expectedOrder, executionOrder);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that cancellation tokens are properly propagated through middleware chains.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AIAgentBuilder_Use_CancellationTokenPropagation_WorksCorrectlyAsync()
|
|
{
|
|
// Arrange
|
|
var expectedToken = new CancellationToken(true);
|
|
var capturedTokens = new List<CancellationToken>();
|
|
|
|
// Setup mock to throw OperationCanceledException when cancelled token is used
|
|
this._innerAgentMock
|
|
.Protected()
|
|
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
|
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
|
ItExpr.IsAny<AgentSession?>(),
|
|
ItExpr.IsAny<AgentRunOptions?>(),
|
|
ItExpr.Is<CancellationToken>(ct => ct.IsCancellationRequested))
|
|
.ThrowsAsync(new OperationCanceledException());
|
|
|
|
var agent = new AIAgentBuilder(this._innerAgentMock.Object)
|
|
.Use(async (messages, session, options, next, cancellationToken) =>
|
|
{
|
|
capturedTokens.Add(cancellationToken);
|
|
await next(messages, session, options, cancellationToken);
|
|
})
|
|
.Use(async (messages, session, options, next, cancellationToken) =>
|
|
{
|
|
capturedTokens.Add(cancellationToken);
|
|
await next(messages, session, options, cancellationToken);
|
|
})
|
|
.Build();
|
|
|
|
// Act & Assert
|
|
await Assert.ThrowsAsync<OperationCanceledException>(
|
|
() => agent.RunAsync(this._testMessages, this._testSession, this._testOptions, expectedToken));
|
|
|
|
Assert.All(capturedTokens, token => Assert.Equal(expectedToken, token));
|
|
Assert.Equal(2, capturedTokens.Count);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that middleware can short-circuit the chain by not calling next.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AIAgentBuilder_Use_MiddlewareShortCircuits_InnerAgentNotCalledAsync()
|
|
{
|
|
// Arrange
|
|
var shortCircuitResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Short-circuited")]);
|
|
var executionOrder = new List<string>();
|
|
|
|
var agent = new AIAgentBuilder(this._innerAgentMock.Object)
|
|
.Use(
|
|
async (messages, session, options, innerAgent, cancellationToken) =>
|
|
{
|
|
executionOrder.Add("First-Pre");
|
|
var result = await innerAgent.RunAsync(messages, session, options, cancellationToken);
|
|
executionOrder.Add("First-Post");
|
|
return result;
|
|
},
|
|
null)
|
|
.Use(
|
|
async (messages, session, options, innerAgent, cancellationToken) =>
|
|
{
|
|
executionOrder.Add("Second-ShortCircuit");
|
|
// Don't call inner agent - short circuit the chain
|
|
return shortCircuitResponse;
|
|
},
|
|
null)
|
|
.Build();
|
|
|
|
// Act
|
|
var result = await agent.RunAsync(this._testMessages, this._testSession, this._testOptions);
|
|
|
|
// Assert
|
|
Assert.Same(shortCircuitResponse, result);
|
|
var expectedOrder = new[] { "First-Pre", "Second-ShortCircuit", "First-Post" };
|
|
Assert.Equal(expectedOrder, executionOrder);
|
|
|
|
// Verify inner agent was never called
|
|
this._innerAgentMock
|
|
.Protected()
|
|
.Verify<Task<AgentResponse>>("RunCoreAsync",
|
|
Times.Never(),
|
|
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
|
ItExpr.IsAny<AgentSession?>(),
|
|
ItExpr.IsAny<AgentRunOptions?>(),
|
|
ItExpr.IsAny<CancellationToken>());
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Helper Methods
|
|
|
|
private static async IAsyncEnumerable<T> ToAsyncEnumerableAsync<T>(IEnumerable<T> items)
|
|
{
|
|
foreach (var item in items)
|
|
{
|
|
await Task.Yield();
|
|
yield return item;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
}
|