mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
0dbcc9fe9d
* update a2a agent to the latest a2a sdk (#5257) * Move A2A samples from 04-hosting to 02-agents (#5267) Move the A2A sample projects (A2AAgent_AsFunctionTools and A2AAgent_PollingForTaskCompletion) from samples/04-hosting/A2A/ to samples/02-agents/A2A/ to better align with the sample directory structure. Update solution file and samples README accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Fix stream reconnection for A2AAgent (#5275) * Add SSE stream reconnection support to A2AAgent Implement automatic reconnection for SSE streams that disconnect mid-task, using the Last-Event-ID header to resume from where the stream left off. Changes: - Add InvokeStreamingWithReconnectAsync method to A2AAgent with configurable max retries and delay between attempts - Add new log messages for reconnection events - Add A2AAgent_StreamReconnection sample demonstrating the feature - Update existing polling sample to use simplified SendMessageAsync API - Add unit tests for stream reconnection logic Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * address comments * Address PR review feedback - Dispose SSE enumerator before GetTaskAsync fallback to release HTTP connection - Wrap StreamWriter in using blocks with leaveOpen:true and explicit UTF-8 encoding - Print update.Text instead of update object in stream reconnection sample Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Use IA2AClientFactory to create A2AClient (#5277) * Refactor A2A extensions to use IA2AClientFactory and add ProtocolSelection sample - Update A2AAgentCardExtensions to accept IA2AClientFactory instead of A2AClientOptions - Update A2ACardResolverExtensions to accept IA2AClientFactory - Update A2AClientExtensions to accept IA2AClientFactory - Update A2AAgent to use IA2AClientFactory for client creation - Add A2AAgent_ProtocolSelection sample demonstrating protocol selection - Add comprehensive unit tests for all changes - Update README files with new sample reference Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Reorder params: options before loggerFactory in A2A extensions Move A2AClientOptions parameter before ILoggerFactory in AsAIAgent and GetAIAgentAsync extension methods to follow the repo convention of keeping LoggerFactory and CancellationToken as the last parameters. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Migrate A2A hosting to A2A SDK v1 (#5363) * .NET: Migrate A2A hosting to A2A SDK v1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * remove unused agent card --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Split A2A endpoint mapping into protocol-specific methods (#5413) * .NET: Refactor A2A hosting registration into A2AServerServiceCollectionExtensions - Rename A2AHostingOptions to A2AServerRegistrationOptions - Move server registration logic from A2AEndpointRouteBuilderExtensions and AIAgentExtensions into new A2AServerServiceCollectionExtensions - Remove A2AProtocolBinding and AIAgentExtensions (consolidated) - Update samples and tests to use the new registration API Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * address copilot comments --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove unnecessary using directive in AgentWebChat.AgentHost Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * restore AsyncEnumerable package version * address copilot initial feedback * address automated code review and formatting issues * fix formatting issues * Add streaming support to A2A agent handler Add HandleNewMessageStreamingAsync to A2AAgentHandler that routes StreamingResponse requests through RunStreamingAsync, enqueuing an A2A Message for each AgentResponseUpdate. Add MessageConverter.ToParts(AgentResponseUpdate) extension to convert streaming update contents to A2A Parts with unsupported-content filtering. Add CreateMessageFromUpdate to map AgentResponseUpdate to A2A Message. Add 16 new tests covering the streaming path and converter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add streaming edge-case tests for A2AAgentHandler Add two tests covering gaps in the streaming path: - ExecuteAsync_Streaming_WhenNoUpdates_EnqueuesNoMessagesAndSavesSessionAsync: Verifies that when RunStreamingAsync yields an empty async enumerable, no messages are enqueued and only SaveSessionAsync runs. - ExecuteAsync_Streaming_CancellationTokenIsPropagatedToRunStreamingAsyncAsync: Verifies that the CancellationToken from ExecuteAsync is propagated through to the inner agent's RunCoreStreamingAsync call. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * address copilot comments --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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;
|
|
}
|