Skip to content

Commit 0c58200

Browse files
authored
.NET: assign AgentCard's URL to mapped-endpoint if not defined explicitly (microsoft#2047)
* fix serialization in chat completions on tools * nit * write e2e test for agent card resolve + adjust behavior * nit
1 parent 97ec214 commit 0c58200

6 files changed

Lines changed: 134 additions & 28 deletions

File tree

dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,8 @@ Once the user has deduced what type (knight or knave) both Alice and Bob are, te
107107
app.UseExceptionHandler();
108108

109109
// attach a2a with simple message communication
110-
app.MapA2A(agentName: "pirate", path: "/a2a/pirate");
111-
app.MapA2A(agentName: "knights-and-knaves", path: "/a2a/knights-and-knaves", agentCard: new()
110+
app.MapA2A(pirateAgentBuilder, path: "/a2a/pirate");
111+
app.MapA2A(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves", agentCard: new()
112112
{
113113
Name = "Knights and Knaves",
114114
Description = "An agent that helps you solve the knights and knaves puzzle.",

dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,16 @@ public static ITaskManager MapA2A(
8383
{
8484
// A2A SDK assigns the url on its own
8585
// we can help user if they did not set Url explicitly.
86-
agentCard.Url ??= context;
86+
if (string.IsNullOrEmpty(agentCard.Url))
87+
{
88+
var agentCardUrl = context.TrimEnd('/');
89+
if (!context.EndsWith("/v1/card", StringComparison.Ordinal))
90+
{
91+
agentCardUrl += "/v1/card";
92+
}
93+
94+
agentCard.Url = agentCardUrl;
95+
}
8796

8897
return Task.FromResult(agentCard);
8998
};
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System;
4+
using System.Text.Json;
5+
using System.Threading.Tasks;
6+
using A2A;
7+
using Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal;
8+
using Microsoft.AspNetCore.Builder;
9+
using Microsoft.AspNetCore.Hosting.Server;
10+
using Microsoft.AspNetCore.TestHost;
11+
using Microsoft.Extensions.AI;
12+
using Microsoft.Extensions.DependencyInjection;
13+
14+
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
15+
16+
public sealed class A2AIntegrationTests
17+
{
18+
/// <summary>
19+
/// Verifies that calling the A2A card endpoint with MapA2A returns an agent card with a URL populated.
20+
/// </summary>
21+
[Fact]
22+
public async Task MapA2A_WithAgentCard_CardEndpointReturnsCardWithUrlAsync()
23+
{
24+
// Arrange
25+
WebApplicationBuilder builder = WebApplication.CreateBuilder();
26+
builder.WebHost.UseTestServer();
27+
28+
IChatClient mockChatClient = new DummyChatClient();
29+
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
30+
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("test-agent", "Test instructions", chatClientServiceKey: "chat-client");
31+
builder.Services.AddLogging();
32+
33+
using WebApplication app = builder.Build();
34+
35+
var agentCard = new AgentCard
36+
{
37+
Name = "Test Agent",
38+
Description = "A test agent for A2A communication",
39+
Version = "1.0"
40+
};
41+
42+
// Map A2A with the agent card
43+
app.MapA2A(agentBuilder, "/a2a/test-agent", agentCard);
44+
45+
await app.StartAsync();
46+
47+
try
48+
{
49+
// Get the test server client
50+
TestServer testServer = app.Services.GetRequiredService<IServer>() as TestServer
51+
?? throw new InvalidOperationException("TestServer not found");
52+
var httpClient = testServer.CreateClient();
53+
54+
// Act - Query the agent card endpoint
55+
var requestUri = new Uri("/a2a/test-agent/v1/card", UriKind.Relative);
56+
var response = await httpClient.GetAsync(requestUri);
57+
58+
// Assert
59+
Assert.True(response.IsSuccessStatusCode, $"Expected successful response but got {response.StatusCode}");
60+
61+
var content = await response.Content.ReadAsStringAsync();
62+
var jsonDoc = JsonDocument.Parse(content);
63+
var root = jsonDoc.RootElement;
64+
65+
// Verify the card has expected properties
66+
Assert.True(root.TryGetProperty("name", out var nameProperty));
67+
Assert.Equal("Test Agent", nameProperty.GetString());
68+
69+
Assert.True(root.TryGetProperty("description", out var descProperty));
70+
Assert.Equal("A test agent for A2A communication", descProperty.GetString());
71+
72+
// Verify the card has a URL property and it's not null/empty
73+
Assert.True(root.TryGetProperty("url", out var urlProperty));
74+
Assert.NotEqual(JsonValueKind.Null, urlProperty.ValueKind);
75+
76+
var url = urlProperty.GetString();
77+
Assert.NotNull(url);
78+
Assert.NotEmpty(url);
79+
Assert.StartsWith("http", url, StringComparison.OrdinalIgnoreCase);
80+
Assert.Equal($"{testServer.BaseAddress.ToString().TrimEnd('/')}/a2a/test-agent/v1/card", url);
81+
}
82+
finally
83+
{
84+
await app.StopAsync();
85+
}
86+
}
87+
}

dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
// Copyright (c) Microsoft. All rights reserved.
22

33
using System;
4-
using System.Collections.Generic;
5-
using System.Threading;
6-
using System.Threading.Tasks;
74
using A2A;
5+
using Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal;
86
using Microsoft.AspNetCore.Builder;
97
using Microsoft.Extensions.AI;
108
using Microsoft.Extensions.DependencyInjection;
@@ -478,25 +476,4 @@ public void MapA2A_WithAgentBuilder_FullAgentCard_Succeeds()
478476
var result = app.MapA2A(agentBuilder, "/a2a", agentCard);
479477
Assert.NotNull(result);
480478
}
481-
482-
private sealed class DummyChatClient : IChatClient
483-
{
484-
public void Dispose()
485-
{
486-
throw new NotImplementedException();
487-
}
488-
489-
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
490-
{
491-
throw new NotImplementedException();
492-
}
493-
494-
public object? GetService(Type serviceType, object? serviceKey = null) =>
495-
serviceType.IsInstanceOfType(this) ? this : null;
496-
497-
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
498-
{
499-
throw new NotImplementedException();
500-
}
501-
}
502479
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using Microsoft.Extensions.AI;
8+
9+
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal;
10+
11+
internal sealed class DummyChatClient : IChatClient
12+
{
13+
public void Dispose()
14+
{
15+
throw new NotImplementedException();
16+
}
17+
18+
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
19+
{
20+
throw new NotImplementedException();
21+
}
22+
23+
public object? GetService(Type serviceType, object? serviceKey = null) =>
24+
serviceType.IsInstanceOfType(this) ? this : null;
25+
26+
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
27+
{
28+
throw new NotImplementedException();
29+
}
30+
}

dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
22

33
<PropertyGroup>
44
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
55
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugCoreTargetFrameworks)</TargetFrameworks>
66
</PropertyGroup>
77

88
<ItemGroup>
9+
<PackageReference Include="Microsoft.AspNetCore.TestHost" VersionOverride="8.0.21" Condition="'$(TargetFramework)' == 'net8.0'" />
10+
<PackageReference Include="Microsoft.AspNetCore.TestHost" Condition="'$(TargetFramework)' != 'net8.0'" />
11+
912
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-rc.2.25502.107" />
1013
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" VersionOverride="10.0.0-rc.2.25502.107" />
1114
</ItemGroup>

0 commit comments

Comments
 (0)