diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AContinuationToken.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AContinuationToken.cs index 5233adb88f..845e1dc6e3 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AContinuationToken.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AContinuationToken.cs @@ -52,7 +52,7 @@ internal class A2AContinuationToken : ResponseContinuationToken { case "taskId": reader.Read(); - taskId = reader.GetString()!; + taskId = reader.GetString() ?? throw new JsonException("The 'taskId' property must contain a non-null string value."); break; default: throw new JsonException($"Unrecognized property '{propertyName}'."); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs index 0a57269776..65bc4c30bd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs @@ -138,7 +138,7 @@ internal sealed class A2AAgentHandler : IAgentHandler catch (Exception) { var failUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId); - await failUpdater.FailAsync(message: null, cancellationToken).ConfigureAwait(false); + await failUpdater.FailAsync(message: null, CancellationToken.None).ConfigureAwait(false); throw; } diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AContinuationTokenTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AContinuationTokenTests.cs index 1bb0d99e00..30d65b12f1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AContinuationTokenTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AContinuationTokenTests.cs @@ -106,6 +106,18 @@ public sealed class A2AContinuationTokenTests Assert.Throws(() => A2AContinuationToken.FromToken(emptyToken)); } + [Fact] + public void FromToken_WithNullTaskIdValue_ThrowsJsonException() + { + // Arrange + var jsonWithNullTaskId = System.Text.Encoding.UTF8.GetBytes("{ \"taskId\": null }").AsMemory(); + var mockToken = new MockResponseContinuationToken(jsonWithNullTaskId); + + // Act & Assert + var ex = Assert.Throws(() => A2AContinuationToken.FromToken(mockToken)); + Assert.Contains("taskId", ex.Message); + } + [Fact] public void FromToken_WithMissingTaskIdProperty_ThrowsException() { diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs index 603376b396..f709e95ea7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs @@ -3,7 +3,6 @@ using System; using System.Collections; using System.Collections.Generic; -using System.Linq; using System.Net; using System.Net.Http; using System.Text; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs index 512e7cc964..7c5287f09f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs @@ -368,6 +368,47 @@ public sealed class A2AAgentHandlerTests Assert.True(events.StatusUpdates.Count > 0); } + /// + /// 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). + /// + [Fact] + public async Task ExecuteAsync_OnContinuation_WhenAgentThrowsWithCancelledToken_StillEmitsFailedStatusAsync() + { + // Arrange + int callCount = 0; + Mock 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(() => + 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); + } + /// /// Verifies that when the agent throws OperationCanceledException during a continuation, /// no Failed status is emitted. diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs index 5c235e649f..e5fa337e86 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs @@ -5,6 +5,7 @@ using Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; +using Moq; namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; @@ -392,6 +393,124 @@ public sealed class A2AEndpointRouteBuilderExtensionsTests Assert.Equal("agentBuilder", exception.ParamName); } + /// + /// Verifies that MapA2AHttpJson throws ArgumentNullException for null AIAgent. + /// + [Fact] + public void MapA2AHttpJson_WithAIAgent_NullAgent_ThrowsArgumentNullException() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + AIAgent agent = null!; + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + app.MapA2AHttpJson(agent, "/a2a")); + + Assert.Equal("agent", exception.ParamName); + } + + /// + /// Verifies that MapA2AHttpJson throws ArgumentNullException for AIAgent with null Name. + /// + [Fact] + public void MapA2AHttpJson_WithAIAgent_NullName_ThrowsArgumentException() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + var agentMock = new Mock(); + agentMock.Setup(a => a.Name).Returns((string?)null); + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + app.MapA2AHttpJson(agentMock.Object, "/a2a")); + + Assert.Equal("agent.Name", exception.ParamName); + } + + /// + /// Verifies that MapA2AHttpJson throws ArgumentException for AIAgent with whitespace Name. + /// + [Fact] + public void MapA2AHttpJson_WithAIAgent_WhitespaceName_ThrowsArgumentException() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + var agentMock = new Mock(); + agentMock.Setup(a => a.Name).Returns(" "); + + // Act & Assert + ArgumentException exception = Assert.Throws(() => + app.MapA2AHttpJson(agentMock.Object, "/a2a")); + + Assert.Equal("agent.Name", exception.ParamName); + } + + /// + /// Verifies that MapA2AJsonRpc throws ArgumentNullException for null AIAgent. + /// + [Fact] + public void MapA2AJsonRpc_WithAIAgent_NullAgent_ThrowsArgumentNullException() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + AIAgent agent = null!; + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + app.MapA2AJsonRpc(agent, "/a2a")); + + Assert.Equal("agent", exception.ParamName); + } + + /// + /// Verifies that MapA2AJsonRpc throws ArgumentNullException for AIAgent with null Name. + /// + [Fact] + public void MapA2AJsonRpc_WithAIAgent_NullName_ThrowsArgumentException() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + var agentMock = new Mock(); + agentMock.Setup(a => a.Name).Returns((string?)null); + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + app.MapA2AJsonRpc(agentMock.Object, "/a2a")); + + Assert.Equal("agent.Name", exception.ParamName); + } + + /// + /// Verifies that MapA2AJsonRpc throws ArgumentException for AIAgent with whitespace Name. + /// + [Fact] + public void MapA2AJsonRpc_WithAIAgent_WhitespaceName_ThrowsArgumentException() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + var agentMock = new Mock(); + agentMock.Setup(a => a.Name).Returns(" "); + + // Act & Assert + ArgumentException exception = Assert.Throws(() => + app.MapA2AJsonRpc(agentMock.Object, "/a2a")); + + Assert.Equal("agent.Name", exception.ParamName); + } + /// /// Verifies that MapA2AHttpJson throws InvalidOperationException when no A2AServer has been /// registered for the specified agent via AddA2AServer. diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs index b263fd14a4..352d07a01a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs @@ -251,6 +251,42 @@ public sealed class A2AServerServiceCollectionExtensionsTests Assert.Throws(() => services.AddA2AServer(agent: null!)); } + /// + /// Verifies that AddA2AServer with an agent instance throws when the agent's Name is null. + /// + [Fact] + public void AddA2AServer_WithAgent_NullName_ThrowsArgumentNullException() + { + // Arrange + var services = new ServiceCollection(); + var agentMock = new Mock(); + agentMock.Setup(a => a.Name).Returns((string?)null); + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + services.AddA2AServer(agentMock.Object)); + + Assert.Equal("agent.Name", exception.ParamName); + } + + /// + /// Verifies that AddA2AServer with an agent instance throws when the agent's Name is whitespace. + /// + [Fact] + public void AddA2AServer_WithAgent_WhitespaceName_ThrowsArgumentException() + { + // Arrange + var services = new ServiceCollection(); + var agentMock = new Mock(); + agentMock.Setup(a => a.Name).Returns(" "); + + // Act & Assert + ArgumentException exception = Assert.Throws(() => + services.AddA2AServer(agentMock.Object)); + + Assert.Equal("agent.Name", exception.ParamName); + } + private static Mock CreateAgentMock(string name) { Mock agentMock = new() { CallBase = true }; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs index 1106802463..e26189e9e5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; using System.Linq; using A2A; using Microsoft.Agents.AI.Hosting.A2A.Converters; @@ -82,4 +83,68 @@ public class MessageConverterTests var textContent = Assert.IsType(chatMessage.Contents.First()); Assert.Equal("Hello, world!", textContent.Text); } + + [Fact] + public void ToParts_NullList_ReturnsEmptyList() + { + // Arrange + IList? messages = null; + + // Act + var result = messages!.ToParts(); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void ToParts_EmptyList_ReturnsEmptyList() + { + // Arrange + IList messages = []; + + // Act + var result = messages.ToParts(); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void ToParts_WithTextContent_ReturnsTextPart() + { + // Arrange + IList messages = + [ + new ChatMessage(ChatRole.Assistant, "Hello from the agent!") + ]; + + // Act + var result = messages.ToParts(); + + // Assert + Assert.Single(result); + Assert.Equal("Hello from the agent!", result[0].Text); + } + + [Fact] + public void ToParts_WithMultipleMessages_ReturnsAllParts() + { + // Arrange + IList messages = + [ + new ChatMessage(ChatRole.User, "First message"), + new ChatMessage(ChatRole.Assistant, "Second message") + ]; + + // Act + var result = messages.ToParts(); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("First message", result[0].Text); + Assert.Equal("Second message", result[1].Text); + } }