db620d33df
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
1782 lines
69 KiB
C#
1782 lines
69 KiB
C#
// 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 A2A;
|
|
using Microsoft.Extensions.AI;
|
|
using Moq;
|
|
using Moq.Protected;
|
|
|
|
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
|
|
|
|
/// <summary>
|
|
/// Unit tests for the <see cref="A2AAgentHandler"/> class.
|
|
/// </summary>
|
|
public sealed class A2AAgentHandlerTests
|
|
{
|
|
/// <summary>
|
|
/// Verifies that when metadata is null, the options passed to RunAsync have
|
|
/// AllowBackgroundResponses disabled and no AdditionalProperties.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_WhenMetadataIsNull_PassesOptionsWithNoAdditionalPropertiesToRunAsync()
|
|
{
|
|
// Arrange
|
|
AgentRunOptions? capturedOptions = null;
|
|
A2AAgentHandler handler = CreateHandler(CreateAgentMock(options => capturedOptions = options));
|
|
|
|
// Act
|
|
await InvokeExecuteAsync(handler, new RequestContext
|
|
{
|
|
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
|
});
|
|
|
|
// Assert
|
|
Assert.NotNull(capturedOptions);
|
|
Assert.False(capturedOptions.AllowBackgroundResponses);
|
|
Assert.Null(capturedOptions.AdditionalProperties);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when metadata is non-empty, the options passed to RunAsync have
|
|
/// AdditionalProperties populated with the converted metadata values.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_WhenMetadataIsNonEmpty_PassesOptionsWithAdditionalPropertiesToRunAsync()
|
|
{
|
|
// Arrange
|
|
AgentRunOptions? capturedOptions = null;
|
|
A2AAgentHandler handler = CreateHandler(CreateAgentMock(options => capturedOptions = options));
|
|
|
|
// Act
|
|
await InvokeExecuteAsync(handler, new RequestContext
|
|
{
|
|
TaskId = "", ContextId = "ctx", StreamingResponse = false,
|
|
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] },
|
|
Metadata = new Dictionary<string, JsonElement>
|
|
{
|
|
["key1"] = JsonSerializer.SerializeToElement("value1"),
|
|
["key2"] = JsonSerializer.SerializeToElement(42)
|
|
}
|
|
});
|
|
|
|
// Assert
|
|
Assert.NotNull(capturedOptions);
|
|
Assert.False(capturedOptions.AllowBackgroundResponses);
|
|
Assert.NotNull(capturedOptions.AdditionalProperties);
|
|
Assert.Equal(2, capturedOptions.AdditionalProperties.Count);
|
|
Assert.Equal("value1", capturedOptions.AdditionalProperties["key1"]?.ToString());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when the agent response has AdditionalProperties, the returned Message.Metadata contains the converted values.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_WhenResponseHasAdditionalProperties_ReturnsMessageWithMetadataAsync()
|
|
{
|
|
// Arrange
|
|
AdditionalPropertiesDictionary additionalProps = new()
|
|
{
|
|
["responseKey1"] = "responseValue1",
|
|
["responseKey2"] = 123
|
|
};
|
|
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
|
|
{
|
|
AdditionalProperties = additionalProps
|
|
};
|
|
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
|
|
|
|
// Act
|
|
var events = await CollectEventsAsync(handler, new RequestContext
|
|
{
|
|
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
|
});
|
|
|
|
// Assert
|
|
Message message = Assert.Single(events.Messages);
|
|
Assert.NotNull(message.Metadata);
|
|
Assert.Equal(2, message.Metadata.Count);
|
|
Assert.True(message.Metadata.ContainsKey("responseKey1"));
|
|
Assert.True(message.Metadata.ContainsKey("responseKey2"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when the agent response has null AdditionalProperties, the returned Message.Metadata is null.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_WhenResponseHasNullAdditionalProperties_ReturnsMessageWithNullMetadataAsync()
|
|
{
|
|
// Arrange
|
|
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
|
|
{
|
|
AdditionalProperties = null
|
|
};
|
|
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
|
|
|
|
// Act
|
|
var events = await CollectEventsAsync(handler, new RequestContext
|
|
{
|
|
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
|
});
|
|
|
|
// Assert
|
|
Message message = Assert.Single(events.Messages);
|
|
Assert.Null(message.Metadata);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when the agent response has empty AdditionalProperties, the returned Message.Metadata is null.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_WhenResponseHasEmptyAdditionalProperties_ReturnsMessageWithNullMetadataAsync()
|
|
{
|
|
// Arrange
|
|
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
|
|
{
|
|
AdditionalProperties = []
|
|
};
|
|
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
|
|
|
|
// Act
|
|
var events = await CollectEventsAsync(handler, new RequestContext
|
|
{
|
|
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
|
});
|
|
|
|
// Assert
|
|
Message message = Assert.Single(events.Messages);
|
|
Assert.Null(message.Metadata);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when runMode is DisallowBackground, AllowBackgroundResponses is false.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_DisallowBackgroundMode_SetsAllowBackgroundResponsesFalseAsync()
|
|
{
|
|
// Arrange
|
|
AgentRunOptions? capturedOptions = null;
|
|
A2AAgentHandler handler = CreateHandler(
|
|
CreateAgentMock(options => capturedOptions = options),
|
|
runMode: AgentRunMode.DisallowBackground);
|
|
|
|
// Act
|
|
await InvokeExecuteAsync(handler, new RequestContext
|
|
{
|
|
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
|
});
|
|
|
|
// Assert
|
|
Assert.NotNull(capturedOptions);
|
|
Assert.False(capturedOptions.AllowBackgroundResponses);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that in AllowBackgroundIfSupported mode, AllowBackgroundResponses is true.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_AllowBackgroundIfSupportedMode_SetsAllowBackgroundResponsesTrueAsync()
|
|
{
|
|
// Arrange
|
|
AgentRunOptions? capturedOptions = null;
|
|
A2AAgentHandler handler = CreateHandler(
|
|
CreateAgentMock(options => capturedOptions = options),
|
|
runMode: AgentRunMode.AllowBackgroundIfSupported);
|
|
|
|
// Act
|
|
await InvokeExecuteAsync(handler, new RequestContext
|
|
{
|
|
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
|
});
|
|
|
|
// Assert
|
|
Assert.NotNull(capturedOptions);
|
|
Assert.True(capturedOptions.AllowBackgroundResponses);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a custom Dynamic delegate returning false sets AllowBackgroundResponses to false.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_DynamicMode_WithFalseCallback_SetsAllowBackgroundResponsesFalseAsync()
|
|
{
|
|
// Arrange
|
|
AgentRunOptions? capturedOptions = null;
|
|
A2AAgentHandler handler = CreateHandler(
|
|
CreateAgentMock(options => capturedOptions = options),
|
|
runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false)));
|
|
|
|
// Act
|
|
await InvokeExecuteAsync(handler, new RequestContext
|
|
{
|
|
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
|
});
|
|
|
|
// Assert
|
|
Assert.NotNull(capturedOptions);
|
|
Assert.False(capturedOptions.AllowBackgroundResponses);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a custom Dynamic delegate returning true sets AllowBackgroundResponses to true.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_DynamicMode_WithTrueCallback_SetsAllowBackgroundResponsesTrueAsync()
|
|
{
|
|
// Arrange
|
|
AgentRunOptions? capturedOptions = null;
|
|
A2AAgentHandler handler = CreateHandler(
|
|
CreateAgentMock(options => capturedOptions = options),
|
|
runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true)));
|
|
|
|
// Act
|
|
await InvokeExecuteAsync(handler, new RequestContext
|
|
{
|
|
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
|
});
|
|
|
|
// Assert
|
|
Assert.NotNull(capturedOptions);
|
|
Assert.True(capturedOptions.AllowBackgroundResponses);
|
|
}
|
|
|
|
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
|
|
|
/// <summary>
|
|
/// Verifies that when the agent returns a ContinuationToken, task status events are emitted.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_WhenResponseHasContinuationToken_EmitsTaskStatusEventsAsync()
|
|
{
|
|
// Arrange
|
|
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
|
|
{
|
|
ContinuationToken = CreateTestContinuationToken()
|
|
};
|
|
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
|
|
|
|
// Act
|
|
var events = await CollectEventsAsync(handler, new RequestContext
|
|
{
|
|
StreamingResponse = false,
|
|
TaskId = "task-1",
|
|
ContextId = "ctx-1",
|
|
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
|
});
|
|
|
|
// Assert - should have emitted status update events (Submitted + Working)
|
|
Assert.True(events.StatusUpdates.Count >= 1);
|
|
Assert.Empty(events.Messages);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when the incoming message has a ContextId, it is used for the response
|
|
/// rather than generating a new one.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_WhenMessageHasContextId_UsesProvidedContextIdAsync()
|
|
{
|
|
// Arrange
|
|
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
|
|
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
|
|
|
|
// Act
|
|
var events = await CollectEventsAsync(handler, new RequestContext
|
|
{
|
|
StreamingResponse = false,
|
|
TaskId = "",
|
|
ContextId = "my-context-123",
|
|
Message = new Message
|
|
{
|
|
MessageId = "test-id",
|
|
ContextId = "my-context-123",
|
|
Role = Role.User,
|
|
Parts = [new Part { Text = "Hello" }]
|
|
}
|
|
});
|
|
|
|
// Assert
|
|
Message message = Assert.Single(events.Messages);
|
|
Assert.Equal("my-context-123", message.ContextId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that on continuation when the agent completes (no ContinuationToken), task is completed with artifact.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_OnContinuation_WhenComplete_EmitsArtifactAndCompletedAsync()
|
|
{
|
|
// Arrange
|
|
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done!")]);
|
|
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
|
|
|
|
// Act
|
|
var events = await CollectEventsAsync(handler, new RequestContext
|
|
{
|
|
StreamingResponse = false,
|
|
Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
|
|
TaskId = "task-1",
|
|
ContextId = "ctx-1",
|
|
|
|
Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
|
|
});
|
|
|
|
// Assert - should have artifact + completed status
|
|
Assert.True(events.ArtifactUpdates.Count > 0);
|
|
Assert.True(events.StatusUpdates.Count > 0);
|
|
Assert.Empty(events.Messages);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when the agent throws during a continuation,
|
|
/// the handler emits a Failed status and re-throws the exception.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_OnContinuation_WhenAgentThrows_EmitsFailedStatusAsync()
|
|
{
|
|
// Arrange
|
|
int callCount = 0;
|
|
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, _ =>
|
|
throw new InvalidOperationException("Agent failed"));
|
|
A2AAgentHandler handler = CreateHandler(agentMock);
|
|
|
|
// Act & Assert
|
|
var events = new EventCollector();
|
|
var eventQueue = new AgentEventQueue();
|
|
var readerTask = ReadEventsAsync(eventQueue, events);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
handler.ExecuteAsync(
|
|
new RequestContext
|
|
{
|
|
StreamingResponse = false,
|
|
Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
|
|
TaskId = "task-1",
|
|
ContextId = "ctx-1",
|
|
|
|
Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
|
|
},
|
|
eventQueue,
|
|
CancellationToken.None));
|
|
eventQueue.Complete(null);
|
|
await readerTask;
|
|
|
|
// Assert - should have emitted Failed status
|
|
Assert.True(events.StatusUpdates.Count > 0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when the agent throws during a continuation and the cancellation token
|
|
/// is already cancelled, the handler still emits a Failed status and re-throws the
|
|
/// original exception (not an OperationCanceledException from FailAsync).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_OnContinuation_WhenAgentThrowsWithCancelledToken_StillEmitsFailedStatusAsync()
|
|
{
|
|
// Arrange
|
|
int callCount = 0;
|
|
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, _ =>
|
|
throw new InvalidOperationException("Agent failed"));
|
|
A2AAgentHandler handler = CreateHandler(agentMock);
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
cts.Cancel(); // Pre-cancel the token
|
|
|
|
// Act & Assert - the original InvalidOperationException should be thrown, not OperationCanceledException
|
|
var events = new EventCollector();
|
|
var eventQueue = new AgentEventQueue();
|
|
var readerTask = ReadEventsAsync(eventQueue, events);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
handler.ExecuteAsync(
|
|
new RequestContext
|
|
{
|
|
StreamingResponse = false,
|
|
Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
|
|
TaskId = "task-1",
|
|
ContextId = "ctx-1",
|
|
|
|
Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
|
|
},
|
|
eventQueue,
|
|
cts.Token));
|
|
eventQueue.Complete(null);
|
|
await readerTask;
|
|
|
|
// Assert - should have emitted Failed status even with a cancelled token
|
|
Assert.True(events.StatusUpdates.Count > 0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when the agent throws OperationCanceledException during a continuation,
|
|
/// no Failed status is emitted.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_OnContinuation_WhenOperationCancelled_DoesNotEmitFailedAsync()
|
|
{
|
|
// Arrange
|
|
int callCount = 0;
|
|
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, _ =>
|
|
throw new OperationCanceledException("Cancelled"));
|
|
A2AAgentHandler handler = CreateHandler(agentMock);
|
|
|
|
// Act & Assert
|
|
var events = new EventCollector();
|
|
var eventQueue = new AgentEventQueue();
|
|
var readerTask = ReadEventsAsync(eventQueue, events);
|
|
await Assert.ThrowsAsync<OperationCanceledException>(() =>
|
|
handler.ExecuteAsync(
|
|
new RequestContext
|
|
{
|
|
StreamingResponse = false,
|
|
Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
|
|
TaskId = "task-1",
|
|
ContextId = "ctx-1",
|
|
|
|
Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
|
|
},
|
|
eventQueue,
|
|
CancellationToken.None));
|
|
eventQueue.Complete(null);
|
|
await readerTask;
|
|
|
|
// Assert - should NOT have emitted any status (OperationCanceledException is re-thrown without marking Failed)
|
|
Assert.Empty(events.StatusUpdates);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that ReferenceTaskIds throws NotSupportedException.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_WithReferenceTaskIds_ThrowsNotSupportedExceptionAsync()
|
|
{
|
|
// Arrange
|
|
A2AAgentHandler handler = CreateHandler(CreateAgentMock(_ => { }));
|
|
|
|
// Act & Assert
|
|
await Assert.ThrowsAsync<NotSupportedException>(() =>
|
|
InvokeExecuteAsync(handler, new RequestContext
|
|
{
|
|
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message
|
|
{
|
|
MessageId = "test-id",
|
|
Role = Role.User,
|
|
Parts = [new Part { Text = "Hello" }],
|
|
ReferenceTaskIds = ["other-task-id"]
|
|
}
|
|
}));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when ContextId is null, a new one is generated and used in the response.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_WhenContextIdIsNull_GeneratesContextIdAsync()
|
|
{
|
|
// Arrange
|
|
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
|
|
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
|
|
|
|
// Act
|
|
var events = await CollectEventsAsync(handler, new RequestContext
|
|
{
|
|
StreamingResponse = false,
|
|
TaskId = "",
|
|
ContextId = null!,
|
|
Message = new Message
|
|
{
|
|
MessageId = "test-id",
|
|
Role = Role.User,
|
|
Parts = [new Part { Text = "Hello" }]
|
|
}
|
|
});
|
|
|
|
// Assert
|
|
Message message = Assert.Single(events.Messages);
|
|
Assert.NotNull(message.ContextId);
|
|
Assert.NotEmpty(message.ContextId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when Message is null, the handler still succeeds with empty chat messages.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_WhenMessageIsNull_SucceedsWithEmptyMessagesAsync()
|
|
{
|
|
// Arrange
|
|
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
|
|
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
|
|
|
|
// Act
|
|
var events = await CollectEventsAsync(handler, new RequestContext
|
|
{
|
|
StreamingResponse = false,
|
|
TaskId = "",
|
|
ContextId = "ctx",
|
|
Message = null!
|
|
});
|
|
|
|
// Assert
|
|
Message message = Assert.Single(events.Messages);
|
|
Assert.Equal("ctx", message.ContextId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that the dynamic AllowBackgroundWhen delegate receives the correct RequestContext.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_DynamicMode_DelegateReceivesRequestContextAsync()
|
|
{
|
|
// Arrange
|
|
A2ARunDecisionContext? capturedContext = null;
|
|
A2AAgentHandler handler = CreateHandler(
|
|
CreateAgentMock(_ => { }),
|
|
runMode: AgentRunMode.AllowBackgroundWhen((ctx, _) =>
|
|
{
|
|
capturedContext = ctx;
|
|
return ValueTask.FromResult(false);
|
|
}));
|
|
|
|
var requestContext = new RequestContext
|
|
{
|
|
TaskId = "my-task", ContextId = "my-ctx", StreamingResponse = false,
|
|
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
|
};
|
|
|
|
// Act
|
|
await InvokeExecuteAsync(handler, requestContext);
|
|
|
|
// Assert
|
|
Assert.NotNull(capturedContext);
|
|
Assert.Same(requestContext, capturedContext.RequestContext);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that CancelAsync emits a Canceled status event.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task CancelAsync_EmitsCanceledStatusAsync()
|
|
{
|
|
// Arrange
|
|
A2AAgentHandler handler = CreateHandler(CreateAgentMock(_ => { }));
|
|
var events = new EventCollector();
|
|
var eventQueue = new AgentEventQueue();
|
|
var readerTask = ReadEventsAsync(eventQueue, events);
|
|
|
|
// Act
|
|
await handler.CancelAsync(
|
|
new RequestContext
|
|
{
|
|
StreamingResponse = false,
|
|
Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
|
|
TaskId = "task-1",
|
|
ContextId = "ctx-1",
|
|
Task = new AgentTask { Id = "task-1", ContextId = "ctx-1" }
|
|
},
|
|
eventQueue,
|
|
CancellationToken.None);
|
|
|
|
// Assert
|
|
eventQueue.Complete(null);
|
|
await readerTask;
|
|
Assert.True(events.StatusUpdates.Count > 0);
|
|
}
|
|
|
|
#pragma warning restore MEAI001
|
|
|
|
/// <summary>
|
|
/// Verifies that in streaming mode, each update from RunStreamingAsync produces a message event.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_Streaming_EnqueuesMessageForEachUpdateAsync()
|
|
{
|
|
// Arrange
|
|
AgentResponseUpdate[] updates =
|
|
[
|
|
new AgentResponseUpdate(ChatRole.Assistant, "chunk 1") { ResponseId = "r1" },
|
|
new AgentResponseUpdate(ChatRole.Assistant, "chunk 2") { ResponseId = "r2" }
|
|
];
|
|
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
|
|
|
|
// Act
|
|
var events = await CollectEventsAsync(handler, new RequestContext
|
|
{
|
|
StreamingResponse = true,
|
|
TaskId = "",
|
|
ContextId = "ctx",
|
|
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
|
});
|
|
|
|
// Assert
|
|
Assert.Equal(2, events.Messages.Count);
|
|
Assert.Equal("chunk 1", events.Messages[0].Parts![0].Text);
|
|
Assert.Equal("chunk 2", events.Messages[1].Parts![0].Text);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that in streaming mode, when metadata is present, options with AdditionalProperties
|
|
/// are passed to RunStreamingAsync.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_Streaming_WithMetadata_PassesOptionsWithAdditionalPropertiesAsync()
|
|
{
|
|
// Arrange
|
|
AgentRunOptions? capturedOptions = null;
|
|
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMockWithOptionsCapture(
|
|
options => capturedOptions = options));
|
|
|
|
// Act
|
|
await InvokeExecuteAsync(handler, new RequestContext
|
|
{
|
|
StreamingResponse = true,
|
|
TaskId = "",
|
|
ContextId = "ctx",
|
|
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] },
|
|
Metadata = new Dictionary<string, JsonElement>
|
|
{
|
|
["key1"] = JsonSerializer.SerializeToElement("value1")
|
|
}
|
|
});
|
|
|
|
// Assert
|
|
Assert.NotNull(capturedOptions);
|
|
Assert.NotNull(capturedOptions.AdditionalProperties);
|
|
Assert.Equal("value1", capturedOptions.AdditionalProperties["key1"]?.ToString());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that in streaming mode, when metadata is null, null options are passed to RunStreamingAsync.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_Streaming_WithNullMetadata_PassesNullOptionsAsync()
|
|
{
|
|
// Arrange
|
|
AgentRunOptions? capturedOptions = null;
|
|
bool optionsCaptured = false;
|
|
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMockWithOptionsCapture(
|
|
options => { capturedOptions = options; optionsCaptured = true; }));
|
|
|
|
// Act
|
|
await InvokeExecuteAsync(handler, new RequestContext
|
|
{
|
|
StreamingResponse = true,
|
|
TaskId = "",
|
|
ContextId = "ctx",
|
|
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
|
});
|
|
|
|
// Assert
|
|
Assert.True(optionsCaptured);
|
|
Assert.Null(capturedOptions);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that in streaming mode, ReferenceTaskIds throws NotSupportedException.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_Streaming_WithReferenceTaskIds_ThrowsNotSupportedExceptionAsync()
|
|
{
|
|
// Arrange
|
|
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock([]));
|
|
|
|
// Act & Assert
|
|
var eventQueue = new AgentEventQueue();
|
|
await Assert.ThrowsAsync<NotSupportedException>(() =>
|
|
handler.ExecuteAsync(
|
|
new RequestContext
|
|
{
|
|
StreamingResponse = true,
|
|
TaskId = "",
|
|
ContextId = "ctx",
|
|
Message = new Message
|
|
{
|
|
MessageId = "test-id",
|
|
Role = Role.User,
|
|
Parts = [new Part { Text = "Hello" }],
|
|
ReferenceTaskIds = ["other-task-id"]
|
|
}
|
|
},
|
|
eventQueue,
|
|
CancellationToken.None));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that in streaming mode, when ContextId is null, a new one is generated.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_Streaming_WhenContextIdIsNull_GeneratesContextIdAsync()
|
|
{
|
|
// Arrange
|
|
AgentResponseUpdate[] updates =
|
|
[
|
|
new AgentResponseUpdate(ChatRole.Assistant, "Reply") { ResponseId = "r1" }
|
|
];
|
|
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
|
|
|
|
// Act
|
|
var events = await CollectEventsAsync(handler, new RequestContext
|
|
{
|
|
StreamingResponse = true,
|
|
TaskId = "",
|
|
ContextId = null!,
|
|
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
|
});
|
|
|
|
// Assert
|
|
Message message = Assert.Single(events.Messages);
|
|
Assert.NotNull(message.ContextId);
|
|
Assert.NotEmpty(message.ContextId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that in streaming mode, the provided ContextId is used in the response.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_Streaming_UsesProvidedContextIdAsync()
|
|
{
|
|
// Arrange
|
|
AgentResponseUpdate[] updates =
|
|
[
|
|
new AgentResponseUpdate(ChatRole.Assistant, "Reply") { ResponseId = "r1" }
|
|
];
|
|
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
|
|
|
|
// Act
|
|
var events = await CollectEventsAsync(handler, new RequestContext
|
|
{
|
|
StreamingResponse = true,
|
|
TaskId = "",
|
|
ContextId = "my-streaming-ctx",
|
|
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
|
});
|
|
|
|
// Assert
|
|
Message message = Assert.Single(events.Messages);
|
|
Assert.Equal("my-streaming-ctx", message.ContextId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that in streaming mode, when Message is null, the handler succeeds with empty messages.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_Streaming_WhenMessageIsNull_SucceedsWithEmptyMessagesAsync()
|
|
{
|
|
// Arrange
|
|
AgentResponseUpdate[] updates =
|
|
[
|
|
new AgentResponseUpdate(ChatRole.Assistant, "Reply") { ResponseId = "r1" }
|
|
];
|
|
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
|
|
|
|
// Act
|
|
var events = await CollectEventsAsync(handler, new RequestContext
|
|
{
|
|
StreamingResponse = true,
|
|
TaskId = "",
|
|
ContextId = "ctx",
|
|
Message = null!
|
|
});
|
|
|
|
// Assert
|
|
Message message = Assert.Single(events.Messages);
|
|
Assert.Equal("ctx", message.ContextId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that in streaming mode, the ResponseId from the update is used as the MessageId in the response.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_Streaming_ResponseIdIsUsedAsMessageIdAsync()
|
|
{
|
|
// Arrange
|
|
AgentResponseUpdate[] updates =
|
|
[
|
|
new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "resp-42" }
|
|
];
|
|
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
|
|
|
|
// Act
|
|
var events = await CollectEventsAsync(handler, new RequestContext
|
|
{
|
|
StreamingResponse = true,
|
|
TaskId = "",
|
|
ContextId = "ctx",
|
|
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
|
});
|
|
|
|
// Assert
|
|
Message message = Assert.Single(events.Messages);
|
|
Assert.Equal("resp-42", message.MessageId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that in streaming mode, when ResponseId is null, a MessageId is still generated.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_Streaming_WhenResponseIdIsNull_GeneratesMessageIdAsync()
|
|
{
|
|
// Arrange
|
|
AgentResponseUpdate[] updates =
|
|
[
|
|
new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = null }
|
|
];
|
|
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
|
|
|
|
// Act
|
|
var events = await CollectEventsAsync(handler, new RequestContext
|
|
{
|
|
StreamingResponse = true,
|
|
TaskId = "",
|
|
ContextId = "ctx",
|
|
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
|
});
|
|
|
|
// Assert
|
|
Message message = Assert.Single(events.Messages);
|
|
Assert.NotNull(message.MessageId);
|
|
Assert.NotEmpty(message.MessageId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that in streaming mode, when the update has AdditionalProperties, the message has metadata.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_Streaming_WithResponseAdditionalProperties_ReturnsMessageWithMetadataAsync()
|
|
{
|
|
// Arrange
|
|
AdditionalPropertiesDictionary additionalProps = new()
|
|
{
|
|
["streamKey"] = "streamValue"
|
|
};
|
|
AgentResponseUpdate[] updates =
|
|
[
|
|
new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "r1", AdditionalProperties = additionalProps }
|
|
];
|
|
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
|
|
|
|
// Act
|
|
var events = await CollectEventsAsync(handler, new RequestContext
|
|
{
|
|
StreamingResponse = true,
|
|
TaskId = "",
|
|
ContextId = "ctx",
|
|
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
|
});
|
|
|
|
// Assert
|
|
Message message = Assert.Single(events.Messages);
|
|
Assert.NotNull(message.Metadata);
|
|
Assert.True(message.Metadata.ContainsKey("streamKey"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that in streaming mode, when the update has null AdditionalProperties, the message has null metadata.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_Streaming_WithNullAdditionalProperties_ReturnsMessageWithNullMetadataAsync()
|
|
{
|
|
// Arrange
|
|
AgentResponseUpdate[] updates =
|
|
[
|
|
new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "r1", AdditionalProperties = null }
|
|
];
|
|
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
|
|
|
|
// Act
|
|
var events = await CollectEventsAsync(handler, new RequestContext
|
|
{
|
|
StreamingResponse = true,
|
|
TaskId = "",
|
|
ContextId = "ctx",
|
|
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
|
});
|
|
|
|
// Assert
|
|
Message message = Assert.Single(events.Messages);
|
|
Assert.Null(message.Metadata);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that in streaming mode, the session is saved after all updates are processed.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_Streaming_SavesSessionAfterProcessingAsync()
|
|
{
|
|
// Arrange
|
|
var mockSessionStore = new Mock<AgentSessionStore>();
|
|
mockSessionStore
|
|
.Setup(x => x.GetSessionAsync(
|
|
It.IsAny<AIAgent>(),
|
|
It.IsAny<string>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new TestAgentSession());
|
|
mockSessionStore
|
|
.Setup(x => x.SaveSessionAsync(
|
|
It.IsAny<AIAgent>(),
|
|
It.IsAny<string>(),
|
|
It.IsAny<AgentSession>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(ValueTask.CompletedTask);
|
|
|
|
AgentResponseUpdate[] updates =
|
|
[
|
|
new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "r1" }
|
|
];
|
|
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates), agentSessionStore: mockSessionStore.Object);
|
|
|
|
// Act
|
|
await InvokeExecuteAsync(handler, new RequestContext
|
|
{
|
|
StreamingResponse = true,
|
|
TaskId = "",
|
|
ContextId = "ctx-stream",
|
|
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
|
});
|
|
|
|
// Assert - verify session was saved
|
|
mockSessionStore.Verify(
|
|
x => x.SaveSessionAsync(
|
|
It.IsAny<AIAgent>(),
|
|
It.Is<string>(s => s == "ctx-stream"),
|
|
It.IsAny<AgentSession>(),
|
|
It.IsAny<CancellationToken>()),
|
|
Times.Once);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that in streaming mode, when RunStreamingAsync yields no updates,
|
|
/// no messages are enqueued and the session is still saved.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_Streaming_WhenNoUpdates_EnqueuesNoMessagesAndSavesSessionAsync()
|
|
{
|
|
// Arrange
|
|
var mockSessionStore = new Mock<AgentSessionStore>();
|
|
mockSessionStore
|
|
.Setup(x => x.GetSessionAsync(
|
|
It.IsAny<AIAgent>(),
|
|
It.IsAny<string>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new TestAgentSession());
|
|
mockSessionStore
|
|
.Setup(x => x.SaveSessionAsync(
|
|
It.IsAny<AIAgent>(),
|
|
It.IsAny<string>(),
|
|
It.IsAny<AgentSession>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(ValueTask.CompletedTask);
|
|
|
|
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock([]), agentSessionStore: mockSessionStore.Object);
|
|
|
|
// Act
|
|
var events = await CollectEventsAsync(handler, new RequestContext
|
|
{
|
|
StreamingResponse = true,
|
|
TaskId = "",
|
|
ContextId = "ctx",
|
|
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
|
});
|
|
|
|
// Assert
|
|
Assert.Empty(events.Messages);
|
|
mockSessionStore.Verify(
|
|
x => x.SaveSessionAsync(
|
|
It.IsAny<AIAgent>(),
|
|
It.Is<string>(s => s == "ctx"),
|
|
It.IsAny<AgentSession>(),
|
|
It.IsAny<CancellationToken>()),
|
|
Times.Once);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that the CancellationToken is propagated to RunStreamingAsync in the streaming path.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_Streaming_CancellationTokenIsPropagatedToRunStreamingAsync()
|
|
{
|
|
// Arrange
|
|
CancellationToken capturedToken = default;
|
|
using var cts = new CancellationTokenSource();
|
|
|
|
Mock<AIAgent> agentMock = new() { CallBase = true };
|
|
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
|
agentMock
|
|
.Protected()
|
|
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
|
.ReturnsAsync(new TestAgentSession());
|
|
agentMock
|
|
.Protected()
|
|
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
|
|
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
|
ItExpr.IsAny<AgentSession?>(),
|
|
ItExpr.IsAny<AgentRunOptions?>(),
|
|
ItExpr.IsAny<CancellationToken>())
|
|
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>(
|
|
(_, _, _, ct) => capturedToken = ct)
|
|
.Returns(() => ToAsyncEnumerableAsync([new AgentResponseUpdate(ChatRole.Assistant, "reply") { ResponseId = "r1" }]));
|
|
|
|
A2AAgentHandler handler = CreateHandler(agentMock);
|
|
|
|
// Act
|
|
var eventQueue = new AgentEventQueue();
|
|
await handler.ExecuteAsync(
|
|
new RequestContext
|
|
{
|
|
TaskId = "",
|
|
ContextId = "ctx",
|
|
StreamingResponse = true,
|
|
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
|
},
|
|
eventQueue,
|
|
cts.Token);
|
|
eventQueue.Complete(null);
|
|
|
|
// Assert
|
|
Assert.Equal(cts.Token, capturedToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when no session store is provided, the handler uses InMemoryAgentSessionStore
|
|
/// and can execute successfully.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Handler_WithNullSessionStore_UsesInMemorySessionStoreAndExecutesSuccessfullyAsync()
|
|
{
|
|
// Arrange
|
|
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
|
|
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response), agentSessionStore: null);
|
|
|
|
// Act
|
|
var events = await CollectEventsAsync(handler, new RequestContext
|
|
{
|
|
StreamingResponse = false,
|
|
TaskId = "",
|
|
ContextId = "ctx-1",
|
|
Message = new Message
|
|
{
|
|
MessageId = "test-id",
|
|
Role = Role.User,
|
|
Parts = [new Part { Text = "Hello" }]
|
|
}
|
|
});
|
|
|
|
// Assert
|
|
Message message = Assert.Single(events.Messages);
|
|
Assert.Equal("Reply", message.Parts![0].Text);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when a custom session store is provided, it is used instead of the
|
|
/// default InMemoryAgentSessionStore.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Handler_WithCustomSessionStore_UsesProvidedSessionStoreAsync()
|
|
{
|
|
// Arrange
|
|
var mockSessionStore = new Mock<AgentSessionStore>();
|
|
mockSessionStore
|
|
.Setup(x => x.GetSessionAsync(
|
|
It.IsAny<AIAgent>(),
|
|
It.IsAny<string>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new TestAgentSession());
|
|
mockSessionStore
|
|
.Setup(x => x.SaveSessionAsync(
|
|
It.IsAny<AIAgent>(),
|
|
It.IsAny<string>(),
|
|
It.IsAny<AgentSession>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(ValueTask.CompletedTask);
|
|
|
|
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
|
|
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response), agentSessionStore: mockSessionStore.Object);
|
|
|
|
// Act
|
|
await InvokeExecuteAsync(handler, new RequestContext
|
|
{
|
|
StreamingResponse = false,
|
|
TaskId = "",
|
|
ContextId = "ctx-1",
|
|
Message = new Message
|
|
{
|
|
MessageId = "test-id",
|
|
Role = Role.User,
|
|
Parts = [new Part { Text = "Hello" }]
|
|
}
|
|
});
|
|
|
|
// Assert - verify the custom session store was called
|
|
mockSessionStore.Verify(
|
|
x => x.GetSessionAsync(
|
|
It.IsAny<AIAgent>(),
|
|
It.Is<string>(s => s == "ctx-1"),
|
|
It.IsAny<CancellationToken>()),
|
|
Times.Once);
|
|
mockSessionStore.Verify(
|
|
x => x.SaveSessionAsync(
|
|
It.IsAny<AIAgent>(),
|
|
It.Is<string>(s => s == "ctx-1"),
|
|
It.IsAny<AgentSession>(),
|
|
It.IsAny<CancellationToken>()),
|
|
Times.Once);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when no session store is provided, the default InMemoryAgentSessionStore
|
|
/// persists sessions across multiple calls with the same context ID.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Handler_WithNullSessionStore_SessionIsPersistedAcrossCallsAsync()
|
|
{
|
|
// Arrange - track how many times CreateSessionCoreAsync is called
|
|
int createSessionCallCount = 0;
|
|
var sessionInstance = new TestAgentSession();
|
|
|
|
Mock<AIAgent> agentMock = new() { CallBase = true };
|
|
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
|
agentMock
|
|
.Protected()
|
|
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
|
.Callback(() => Interlocked.Increment(ref createSessionCallCount))
|
|
.ReturnsAsync(() => new TestAgentSession());
|
|
agentMock
|
|
.Protected()
|
|
.Setup<ValueTask<JsonElement>>("SerializeSessionCoreAsync",
|
|
ItExpr.IsAny<AgentSession>(),
|
|
ItExpr.IsAny<JsonSerializerOptions?>(),
|
|
ItExpr.IsAny<CancellationToken>())
|
|
.ReturnsAsync(JsonDocument.Parse("{}").RootElement);
|
|
agentMock
|
|
.Protected()
|
|
.Setup<ValueTask<AgentSession>>("DeserializeSessionCoreAsync",
|
|
ItExpr.IsAny<JsonElement>(),
|
|
ItExpr.IsAny<JsonSerializerOptions?>(),
|
|
ItExpr.IsAny<CancellationToken>())
|
|
.ReturnsAsync(sessionInstance);
|
|
agentMock
|
|
.Protected()
|
|
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
|
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
|
ItExpr.IsAny<AgentSession?>(),
|
|
ItExpr.IsAny<AgentRunOptions?>(),
|
|
ItExpr.IsAny<CancellationToken>())
|
|
.ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Reply")]));
|
|
|
|
A2AAgentHandler handler = CreateHandler(agentMock, agentSessionStore: null);
|
|
|
|
var context = new RequestContext
|
|
{
|
|
StreamingResponse = false,
|
|
TaskId = "",
|
|
ContextId = "ctx-persistent",
|
|
Message = new Message
|
|
{
|
|
MessageId = "test-id",
|
|
Role = Role.User,
|
|
Parts = [new Part { Text = "Hello" }]
|
|
}
|
|
};
|
|
|
|
// Act - call twice with the same context ID
|
|
await InvokeExecuteAsync(handler, context);
|
|
await InvokeExecuteAsync(handler, context);
|
|
|
|
// Assert - CreateSessionCoreAsync should be called once (first call creates, second retrieves from store)
|
|
Assert.Equal(1, createSessionCallCount);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that when the AllowBackgroundWhen delegate throws, the exception propagates
|
|
/// and the agent is not invoked.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_DynamicMode_WhenCallbackThrows_PropagatesExceptionAsync()
|
|
{
|
|
// Arrange
|
|
bool agentInvoked = false;
|
|
A2AAgentHandler handler = CreateHandler(
|
|
CreateAgentMock(_ => agentInvoked = true),
|
|
runMode: AgentRunMode.AllowBackgroundWhen((_, _) =>
|
|
throw new InvalidOperationException("Callback failed")));
|
|
|
|
// Act & Assert
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
InvokeExecuteAsync(handler, new RequestContext
|
|
{
|
|
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
|
}));
|
|
|
|
Assert.False(agentInvoked);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that the CancellationToken is propagated to the AllowBackgroundWhen delegate.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_DynamicMode_CancellationTokenIsPropagatedToCallbackAsync()
|
|
{
|
|
// Arrange
|
|
CancellationToken capturedToken = default;
|
|
using var cts = new CancellationTokenSource();
|
|
A2AAgentHandler handler = CreateHandler(
|
|
CreateAgentMock(_ => { }),
|
|
runMode: AgentRunMode.AllowBackgroundWhen((_, ct) =>
|
|
{
|
|
capturedToken = ct;
|
|
return ValueTask.FromResult(false);
|
|
}));
|
|
|
|
// Act
|
|
var eventQueue = new AgentEventQueue();
|
|
await handler.ExecuteAsync(
|
|
new RequestContext
|
|
{
|
|
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
|
},
|
|
eventQueue,
|
|
cts.Token);
|
|
eventQueue.Complete(null);
|
|
|
|
// Assert
|
|
Assert.Equal(cts.Token, capturedToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that the agent run mode is applied on the continuation/task-update path,
|
|
/// not just the new message path.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_OnContinuation_RunModeIsAppliedAsync()
|
|
{
|
|
// Arrange
|
|
AgentRunOptions? capturedOptions = null;
|
|
A2AAgentHandler handler = CreateHandler(
|
|
CreateAgentMock(options => capturedOptions = options),
|
|
runMode: AgentRunMode.AllowBackgroundIfSupported);
|
|
|
|
// Act
|
|
await InvokeExecuteAsync(handler, new RequestContext
|
|
{
|
|
StreamingResponse = false,
|
|
TaskId = "task-1",
|
|
ContextId = "ctx-1",
|
|
Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
|
|
|
|
Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
|
|
});
|
|
|
|
// Assert
|
|
Assert.NotNull(capturedOptions);
|
|
Assert.True(capturedOptions.AllowBackgroundResponses);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that in the non-streaming path, SaveSessionAsync is called with
|
|
/// CancellationToken.None even when RunAsync throws an exception.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_NonStreaming_WhenRunAsyncThrows_SavesSessionWithUncancelledTokenAsync()
|
|
{
|
|
// Arrange
|
|
var mockSessionStore = new Mock<AgentSessionStore>();
|
|
mockSessionStore
|
|
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new TestAgentSession());
|
|
mockSessionStore
|
|
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
|
.Returns(ValueTask.CompletedTask);
|
|
|
|
Mock<AIAgent> agentMock = new() { CallBase = true };
|
|
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
|
agentMock.Protected()
|
|
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
|
.ReturnsAsync(new TestAgentSession());
|
|
agentMock.Protected()
|
|
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
|
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
|
ItExpr.IsAny<AgentSession?>(),
|
|
ItExpr.IsAny<AgentRunOptions?>(),
|
|
ItExpr.IsAny<CancellationToken>())
|
|
.ThrowsAsync(new InvalidOperationException("Agent failed"));
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
A2AAgentHandler handler = CreateHandler(agentMock, agentSessionStore: mockSessionStore.Object);
|
|
|
|
// Act
|
|
var eventQueue = new AgentEventQueue();
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
handler.ExecuteAsync(
|
|
new RequestContext
|
|
{
|
|
TaskId = "", ContextId = "ctx", StreamingResponse = false,
|
|
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
|
},
|
|
eventQueue,
|
|
cts.Token));
|
|
|
|
// Assert - SaveSessionAsync was called with CancellationToken.None despite the exception
|
|
mockSessionStore.Verify(
|
|
x => x.SaveSessionAsync(
|
|
It.IsAny<AIAgent>(),
|
|
It.Is<string>(s => s == "ctx"),
|
|
It.IsAny<AgentSession>(),
|
|
It.Is<CancellationToken>(ct => ct == CancellationToken.None)),
|
|
Times.Once);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that in the streaming path, SaveSessionAsync is called with
|
|
/// CancellationToken.None even when RunStreamingAsync throws an exception.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_Streaming_WhenRunStreamingAsyncThrows_SavesSessionWithUncancelledTokenAsync()
|
|
{
|
|
// Arrange
|
|
var mockSessionStore = new Mock<AgentSessionStore>();
|
|
mockSessionStore
|
|
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new TestAgentSession());
|
|
mockSessionStore
|
|
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
|
.Returns(ValueTask.CompletedTask);
|
|
|
|
Mock<AIAgent> agentMock = new() { CallBase = true };
|
|
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
|
agentMock.Protected()
|
|
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
|
.ReturnsAsync(new TestAgentSession());
|
|
agentMock.Protected()
|
|
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
|
|
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
|
ItExpr.IsAny<AgentSession?>(),
|
|
ItExpr.IsAny<AgentRunOptions?>(),
|
|
ItExpr.IsAny<CancellationToken>())
|
|
.Returns(() => ToThrowingAsyncEnumerableAsync(new InvalidOperationException("Stream failed")));
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
A2AAgentHandler handler = CreateHandler(agentMock, agentSessionStore: mockSessionStore.Object);
|
|
|
|
// Act
|
|
var eventQueue = new AgentEventQueue();
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
handler.ExecuteAsync(
|
|
new RequestContext
|
|
{
|
|
TaskId = "", ContextId = "ctx-stream", StreamingResponse = true,
|
|
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
|
},
|
|
eventQueue,
|
|
cts.Token));
|
|
|
|
// Assert - SaveSessionAsync was called with CancellationToken.None despite the exception
|
|
mockSessionStore.Verify(
|
|
x => x.SaveSessionAsync(
|
|
It.IsAny<AIAgent>(),
|
|
It.Is<string>(s => s == "ctx-stream"),
|
|
It.IsAny<AgentSession>(),
|
|
It.Is<CancellationToken>(ct => ct == CancellationToken.None)),
|
|
Times.Once);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that on the continuation path, SaveSessionAsync is called with
|
|
/// CancellationToken.None even when RunAsync throws an exception.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_OnContinuation_WhenRunAsyncThrows_SavesSessionWithUncancelledTokenAsync()
|
|
{
|
|
// Arrange
|
|
var mockSessionStore = new Mock<AgentSessionStore>();
|
|
mockSessionStore
|
|
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new TestAgentSession());
|
|
mockSessionStore
|
|
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
|
.Returns(ValueTask.CompletedTask);
|
|
|
|
Mock<AIAgent> agentMock = new() { CallBase = true };
|
|
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
|
agentMock.Protected()
|
|
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
|
.ReturnsAsync(new TestAgentSession());
|
|
agentMock.Protected()
|
|
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
|
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
|
ItExpr.IsAny<AgentSession?>(),
|
|
ItExpr.IsAny<AgentRunOptions?>(),
|
|
ItExpr.IsAny<CancellationToken>())
|
|
.ThrowsAsync(new InvalidOperationException("Agent failed"));
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
A2AAgentHandler handler = CreateHandler(agentMock, agentSessionStore: mockSessionStore.Object);
|
|
|
|
// Act
|
|
var eventQueue = new AgentEventQueue();
|
|
var events = new EventCollector();
|
|
var readerTask = ReadEventsAsync(eventQueue, events);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
handler.ExecuteAsync(
|
|
new RequestContext
|
|
{
|
|
StreamingResponse = false,
|
|
TaskId = "task-1", ContextId = "ctx-cont",
|
|
Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
|
|
Task = new AgentTask { Id = "task-1", ContextId = "ctx-cont", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
|
|
},
|
|
eventQueue,
|
|
cts.Token));
|
|
eventQueue.Complete(null);
|
|
await readerTask;
|
|
|
|
// Assert - SaveSessionAsync was called with CancellationToken.None despite the exception
|
|
mockSessionStore.Verify(
|
|
x => x.SaveSessionAsync(
|
|
It.IsAny<AIAgent>(),
|
|
It.Is<string>(s => s == "ctx-cont"),
|
|
It.IsAny<AgentSession>(),
|
|
It.Is<CancellationToken>(ct => ct == CancellationToken.None)),
|
|
Times.Once);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that in the non-streaming path, SaveSessionAsync is called with
|
|
/// CancellationToken.None rather than the caller's cancellation token.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_NonStreaming_SavesSessionWithUncancelledTokenAsync()
|
|
{
|
|
// Arrange
|
|
var mockSessionStore = new Mock<AgentSessionStore>();
|
|
mockSessionStore
|
|
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new TestAgentSession());
|
|
mockSessionStore
|
|
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
|
.Returns(ValueTask.CompletedTask);
|
|
|
|
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
|
|
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response), agentSessionStore: mockSessionStore.Object);
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
|
|
// Act
|
|
var eventQueue = new AgentEventQueue();
|
|
await handler.ExecuteAsync(
|
|
new RequestContext
|
|
{
|
|
TaskId = "", ContextId = "ctx", StreamingResponse = false,
|
|
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
|
},
|
|
eventQueue,
|
|
cts.Token);
|
|
eventQueue.Complete(null);
|
|
|
|
// Assert - SaveSessionAsync was called with CancellationToken.None, not the caller's token
|
|
mockSessionStore.Verify(
|
|
x => x.SaveSessionAsync(
|
|
It.IsAny<AIAgent>(),
|
|
It.Is<string>(s => s == "ctx"),
|
|
It.IsAny<AgentSession>(),
|
|
It.Is<CancellationToken>(ct => ct == CancellationToken.None)),
|
|
Times.Once);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that in the streaming path, SaveSessionAsync is called with
|
|
/// CancellationToken.None rather than the caller's cancellation token.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_Streaming_SavesSessionWithUncancelledTokenAsync()
|
|
{
|
|
// Arrange
|
|
var mockSessionStore = new Mock<AgentSessionStore>();
|
|
mockSessionStore
|
|
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new TestAgentSession());
|
|
mockSessionStore
|
|
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
|
.Returns(ValueTask.CompletedTask);
|
|
|
|
AgentResponseUpdate[] updates = [new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "r1" }];
|
|
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates), agentSessionStore: mockSessionStore.Object);
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
|
|
// Act
|
|
var eventQueue = new AgentEventQueue();
|
|
await handler.ExecuteAsync(
|
|
new RequestContext
|
|
{
|
|
TaskId = "", ContextId = "ctx-stream", StreamingResponse = true,
|
|
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
|
},
|
|
eventQueue,
|
|
cts.Token);
|
|
eventQueue.Complete(null);
|
|
|
|
// Assert - SaveSessionAsync was called with CancellationToken.None, not the caller's token
|
|
mockSessionStore.Verify(
|
|
x => x.SaveSessionAsync(
|
|
It.IsAny<AIAgent>(),
|
|
It.Is<string>(s => s == "ctx-stream"),
|
|
It.IsAny<AgentSession>(),
|
|
It.Is<CancellationToken>(ct => ct == CancellationToken.None)),
|
|
Times.Once);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that on the continuation path, SaveSessionAsync is called with
|
|
/// CancellationToken.None rather than the caller's cancellation token.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteAsync_OnContinuation_SavesSessionWithUncancelledTokenAsync()
|
|
{
|
|
// Arrange
|
|
var mockSessionStore = new Mock<AgentSessionStore>();
|
|
mockSessionStore
|
|
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new TestAgentSession());
|
|
mockSessionStore
|
|
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
|
.Returns(ValueTask.CompletedTask);
|
|
|
|
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done!")]);
|
|
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response), agentSessionStore: mockSessionStore.Object);
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
|
|
// Act
|
|
var eventQueue = new AgentEventQueue();
|
|
var events = new EventCollector();
|
|
var readerTask = ReadEventsAsync(eventQueue, events);
|
|
await handler.ExecuteAsync(
|
|
new RequestContext
|
|
{
|
|
StreamingResponse = false,
|
|
TaskId = "task-1", ContextId = "ctx-cont",
|
|
Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
|
|
Task = new AgentTask { Id = "task-1", ContextId = "ctx-cont", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
|
|
},
|
|
eventQueue,
|
|
cts.Token);
|
|
eventQueue.Complete(null);
|
|
await readerTask;
|
|
|
|
// Assert - SaveSessionAsync was called with CancellationToken.None, not the caller's token
|
|
mockSessionStore.Verify(
|
|
x => x.SaveSessionAsync(
|
|
It.IsAny<AIAgent>(),
|
|
It.Is<string>(s => s == "ctx-cont"),
|
|
It.IsAny<AgentSession>(),
|
|
It.Is<CancellationToken>(ct => ct == CancellationToken.None)),
|
|
Times.Once);
|
|
}
|
|
|
|
private static A2AAgentHandler CreateHandler(
|
|
Mock<AIAgent> agentMock,
|
|
AgentRunMode? runMode = null,
|
|
AgentSessionStore? agentSessionStore = null)
|
|
{
|
|
runMode ??= AgentRunMode.DisallowBackground;
|
|
|
|
var hostAgent = new AIHostAgent(
|
|
innerAgent: agentMock.Object,
|
|
sessionStore: agentSessionStore ?? new InMemoryAgentSessionStore());
|
|
|
|
return new A2AAgentHandler(hostAgent, runMode);
|
|
}
|
|
|
|
private static Mock<AIAgent> CreateAgentMock(Action<AgentRunOptions?> optionsCallback)
|
|
{
|
|
Mock<AIAgent> agentMock = new() { CallBase = true };
|
|
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
|
agentMock
|
|
.Protected()
|
|
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
|
.ReturnsAsync(new TestAgentSession());
|
|
agentMock
|
|
.Protected()
|
|
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
|
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
|
ItExpr.IsAny<AgentSession?>(),
|
|
ItExpr.IsAny<AgentRunOptions?>(),
|
|
ItExpr.IsAny<CancellationToken>())
|
|
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>(
|
|
(_, _, options, _) => optionsCallback(options))
|
|
.ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Test response")]));
|
|
|
|
return agentMock;
|
|
}
|
|
|
|
private static Mock<AIAgent> CreateAgentMockWithResponse(AgentResponse response)
|
|
{
|
|
Mock<AIAgent> agentMock = new() { CallBase = true };
|
|
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
|
agentMock
|
|
.Protected()
|
|
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
|
.ReturnsAsync(new TestAgentSession());
|
|
agentMock
|
|
.Protected()
|
|
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
|
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
|
ItExpr.IsAny<AgentSession?>(),
|
|
ItExpr.IsAny<AgentRunOptions?>(),
|
|
ItExpr.IsAny<CancellationToken>())
|
|
.ReturnsAsync(response);
|
|
|
|
return agentMock;
|
|
}
|
|
|
|
private static Mock<AIAgent> CreateAgentMockWithCallCount(
|
|
ref int callCount,
|
|
Func<int, AgentResponse> responseFactory)
|
|
{
|
|
StrongBox<int> callCountBox = new(callCount);
|
|
|
|
Mock<AIAgent> agentMock = new() { CallBase = true };
|
|
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
|
agentMock
|
|
.Protected()
|
|
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
|
.ReturnsAsync(new TestAgentSession());
|
|
agentMock
|
|
.Protected()
|
|
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
|
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
|
ItExpr.IsAny<AgentSession?>(),
|
|
ItExpr.IsAny<AgentRunOptions?>(),
|
|
ItExpr.IsAny<CancellationToken>())
|
|
.ReturnsAsync(() =>
|
|
{
|
|
int currentCall = Interlocked.Increment(ref callCountBox.Value);
|
|
return responseFactory(currentCall);
|
|
});
|
|
|
|
return agentMock;
|
|
}
|
|
|
|
private static Mock<AIAgent> CreateStreamingAgentMock(IEnumerable<AgentResponseUpdate> updates)
|
|
{
|
|
Mock<AIAgent> agentMock = new() { CallBase = true };
|
|
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
|
agentMock
|
|
.Protected()
|
|
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
|
.ReturnsAsync(new TestAgentSession());
|
|
agentMock
|
|
.Protected()
|
|
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
|
|
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
|
ItExpr.IsAny<AgentSession?>(),
|
|
ItExpr.IsAny<AgentRunOptions?>(),
|
|
ItExpr.IsAny<CancellationToken>())
|
|
.Returns(() => ToAsyncEnumerableAsync(updates));
|
|
|
|
return agentMock;
|
|
}
|
|
|
|
private static Mock<AIAgent> CreateStreamingAgentMockWithOptionsCapture(
|
|
Action<AgentRunOptions?> optionsCallback)
|
|
{
|
|
Mock<AIAgent> agentMock = new() { CallBase = true };
|
|
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
|
agentMock
|
|
.Protected()
|
|
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
|
.ReturnsAsync(new TestAgentSession());
|
|
agentMock
|
|
.Protected()
|
|
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
|
|
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
|
ItExpr.IsAny<AgentSession?>(),
|
|
ItExpr.IsAny<AgentRunOptions?>(),
|
|
ItExpr.IsAny<CancellationToken>())
|
|
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>(
|
|
(_, _, options, _) => optionsCallback(options))
|
|
.Returns(() => ToAsyncEnumerableAsync([new AgentResponseUpdate(ChatRole.Assistant, "reply") { ResponseId = "r1" }]));
|
|
|
|
return agentMock;
|
|
}
|
|
|
|
private static async IAsyncEnumerable<T> ToAsyncEnumerableAsync<T>(IEnumerable<T> items)
|
|
{
|
|
await Task.Yield();
|
|
foreach (var item in items)
|
|
{
|
|
yield return item;
|
|
}
|
|
}
|
|
|
|
private static async IAsyncEnumerable<AgentResponseUpdate> ToThrowingAsyncEnumerableAsync(Exception exception)
|
|
{
|
|
await Task.Yield();
|
|
throw exception;
|
|
|
|
#pragma warning disable CS0162 // Unreachable code detected - yield is required for async iterator
|
|
yield break;
|
|
#pragma warning restore CS0162
|
|
}
|
|
|
|
private static async Task InvokeExecuteAsync(A2AAgentHandler handler, RequestContext context)
|
|
{
|
|
var eventQueue = new AgentEventQueue();
|
|
await handler.ExecuteAsync(context, eventQueue, CancellationToken.None);
|
|
eventQueue.Complete(null);
|
|
}
|
|
|
|
private static async Task<EventCollector> CollectEventsAsync(A2AAgentHandler handler, RequestContext context)
|
|
{
|
|
var events = new EventCollector();
|
|
var eventQueue = new AgentEventQueue();
|
|
var readerTask = ReadEventsAsync(eventQueue, events);
|
|
|
|
await handler.ExecuteAsync(context, eventQueue, CancellationToken.None);
|
|
eventQueue.Complete(null);
|
|
await readerTask;
|
|
|
|
return events;
|
|
}
|
|
|
|
private static async Task ReadEventsAsync(AgentEventQueue eventQueue, EventCollector collector)
|
|
{
|
|
await foreach (var response in eventQueue)
|
|
{
|
|
switch (response.PayloadCase)
|
|
{
|
|
case StreamResponseCase.Message:
|
|
collector.Messages.Add(response.Message!);
|
|
break;
|
|
case StreamResponseCase.Task:
|
|
collector.Tasks.Add(response.Task!);
|
|
break;
|
|
case StreamResponseCase.StatusUpdate:
|
|
collector.StatusUpdates.Add(response.StatusUpdate!);
|
|
break;
|
|
case StreamResponseCase.ArtifactUpdate:
|
|
collector.ArtifactUpdates.Add(response.ArtifactUpdate!);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
#pragma warning disable MEAI001
|
|
private static ResponseContinuationToken CreateTestContinuationToken()
|
|
{
|
|
return ResponseContinuationToken.FromBytes(new byte[] { 0x01, 0x02, 0x03 });
|
|
}
|
|
#pragma warning restore MEAI001
|
|
|
|
private sealed class EventCollector
|
|
{
|
|
public List<Message> Messages { get; } = [];
|
|
public List<AgentTask> Tasks { get; } = [];
|
|
public List<TaskStatusUpdateEvent> StatusUpdates { get; } = [];
|
|
public List<TaskArtifactUpdateEvent> ArtifactUpdates { get; } = [];
|
|
}
|
|
|
|
private sealed class TestAgentSession : AgentSession;
|
|
}
|