Skip to content

Commit e033bb0

Browse files
authored
.NET: Fix AG-UI forwardedProps JSON property name (microsoft#2543)
* Fix AG-UI forwardedProps JSON property name The RunAgentInput.ForwardedProperties property was using the wrong JSON property name 'forwardedProperties' instead of 'forwardedProps' per the AG-UI protocol specification. This fix: - Changes JsonPropertyName from 'forwardedProperties' to 'forwardedProps' - Adds comprehensive integration tests for forwarded properties Fixes microsoft#2468 * Fix formatting
1 parent 798aaae commit e033bb0

File tree

2 files changed

+359
-1
lines changed

2 files changed

+359
-1
lines changed

dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunAgentInput.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ internal sealed class RunAgentInput
3232
[JsonPropertyName("context")]
3333
public AGUIContextItem[] Context { get; set; } = [];
3434

35-
[JsonPropertyName("forwardedProperties")]
35+
[JsonPropertyName("forwardedProps")]
3636
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
3737
public JsonElement ForwardedProperties { get; set; }
3838
}
Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Diagnostics.CodeAnalysis;
6+
using System.IO;
7+
using System.Net.Http;
8+
using System.Net.ServerSentEvents;
9+
using System.Runtime.CompilerServices;
10+
using System.Text;
11+
using System.Text.Json;
12+
using System.Threading;
13+
using System.Threading.Tasks;
14+
using FluentAssertions;
15+
using Microsoft.AspNetCore.Builder;
16+
using Microsoft.AspNetCore.Hosting.Server;
17+
using Microsoft.AspNetCore.TestHost;
18+
using Microsoft.Extensions.AI;
19+
using Microsoft.Extensions.DependencyInjection;
20+
21+
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests;
22+
23+
public sealed class ForwardedPropertiesTests : IAsyncDisposable
24+
{
25+
private WebApplication? _app;
26+
private HttpClient? _client;
27+
28+
[Fact]
29+
public async Task ForwardedProps_AreParsedAndPassedToAgent_WhenProvidedInRequestAsync()
30+
{
31+
// Arrange
32+
FakeForwardedPropsAgent fakeAgent = new();
33+
await this.SetupTestServerAsync(fakeAgent);
34+
35+
// Create request JSON with forwardedProps (per AG-UI protocol spec)
36+
const string RequestJson = """
37+
{
38+
"threadId": "thread-123",
39+
"runId": "run-456",
40+
"messages": [{ "id": "msg-1", "role": "user", "content": "test forwarded props" }],
41+
"forwardedProps": { "customProp": "customValue", "sessionId": "test-session-123" }
42+
}
43+
""";
44+
45+
using StringContent content = new(RequestJson, Encoding.UTF8, "application/json");
46+
47+
// Act
48+
HttpResponseMessage response = await this._client!.PostAsync(new Uri("/agent", UriKind.Relative), content);
49+
50+
// Assert
51+
response.IsSuccessStatusCode.Should().BeTrue();
52+
fakeAgent.ReceivedForwardedProperties.ValueKind.Should().Be(JsonValueKind.Object);
53+
fakeAgent.ReceivedForwardedProperties.GetProperty("customProp").GetString().Should().Be("customValue");
54+
fakeAgent.ReceivedForwardedProperties.GetProperty("sessionId").GetString().Should().Be("test-session-123");
55+
}
56+
57+
[Fact]
58+
public async Task ForwardedProps_WithNestedObjects_AreCorrectlyParsedAsync()
59+
{
60+
// Arrange
61+
FakeForwardedPropsAgent fakeAgent = new();
62+
await this.SetupTestServerAsync(fakeAgent);
63+
64+
const string RequestJson = """
65+
{
66+
"threadId": "thread-123",
67+
"runId": "run-456",
68+
"messages": [{ "id": "msg-1", "role": "user", "content": "test nested props" }],
69+
"forwardedProps": {
70+
"user": { "id": "user-1", "name": "Test User" },
71+
"metadata": { "version": "1.0", "feature": "test" }
72+
}
73+
}
74+
""";
75+
76+
using StringContent content = new(RequestJson, Encoding.UTF8, "application/json");
77+
78+
// Act
79+
HttpResponseMessage response = await this._client!.PostAsync(new Uri("/agent", UriKind.Relative), content);
80+
81+
// Assert
82+
response.IsSuccessStatusCode.Should().BeTrue();
83+
fakeAgent.ReceivedForwardedProperties.ValueKind.Should().Be(JsonValueKind.Object);
84+
85+
JsonElement user = fakeAgent.ReceivedForwardedProperties.GetProperty("user");
86+
user.GetProperty("id").GetString().Should().Be("user-1");
87+
user.GetProperty("name").GetString().Should().Be("Test User");
88+
89+
JsonElement metadata = fakeAgent.ReceivedForwardedProperties.GetProperty("metadata");
90+
metadata.GetProperty("version").GetString().Should().Be("1.0");
91+
metadata.GetProperty("feature").GetString().Should().Be("test");
92+
}
93+
94+
[Fact]
95+
public async Task ForwardedProps_WithArrays_AreCorrectlyParsedAsync()
96+
{
97+
// Arrange
98+
FakeForwardedPropsAgent fakeAgent = new();
99+
await this.SetupTestServerAsync(fakeAgent);
100+
101+
const string RequestJson = """
102+
{
103+
"threadId": "thread-123",
104+
"runId": "run-456",
105+
"messages": [{ "id": "msg-1", "role": "user", "content": "test array props" }],
106+
"forwardedProps": {
107+
"tags": ["tag1", "tag2", "tag3"],
108+
"scores": [1, 2, 3, 4, 5]
109+
}
110+
}
111+
""";
112+
113+
using StringContent content = new(RequestJson, Encoding.UTF8, "application/json");
114+
115+
// Act
116+
HttpResponseMessage response = await this._client!.PostAsync(new Uri("/agent", UriKind.Relative), content);
117+
118+
// Assert
119+
response.IsSuccessStatusCode.Should().BeTrue();
120+
fakeAgent.ReceivedForwardedProperties.ValueKind.Should().Be(JsonValueKind.Object);
121+
122+
JsonElement tags = fakeAgent.ReceivedForwardedProperties.GetProperty("tags");
123+
tags.GetArrayLength().Should().Be(3);
124+
tags[0].GetString().Should().Be("tag1");
125+
126+
JsonElement scores = fakeAgent.ReceivedForwardedProperties.GetProperty("scores");
127+
scores.GetArrayLength().Should().Be(5);
128+
scores[2].GetInt32().Should().Be(3);
129+
}
130+
131+
[Fact]
132+
public async Task ForwardedProps_WhenEmpty_DoesNotCauseErrorsAsync()
133+
{
134+
// Arrange
135+
FakeForwardedPropsAgent fakeAgent = new();
136+
await this.SetupTestServerAsync(fakeAgent);
137+
138+
const string RequestJson = """
139+
{
140+
"threadId": "thread-123",
141+
"runId": "run-456",
142+
"messages": [{ "id": "msg-1", "role": "user", "content": "test empty props" }],
143+
"forwardedProps": {}
144+
}
145+
""";
146+
147+
using StringContent content = new(RequestJson, Encoding.UTF8, "application/json");
148+
149+
// Act
150+
HttpResponseMessage response = await this._client!.PostAsync(new Uri("/agent", UriKind.Relative), content);
151+
152+
// Assert
153+
response.IsSuccessStatusCode.Should().BeTrue();
154+
}
155+
156+
[Fact]
157+
public async Task ForwardedProps_WhenNotProvided_AgentStillWorksAsync()
158+
{
159+
// Arrange
160+
FakeForwardedPropsAgent fakeAgent = new();
161+
await this.SetupTestServerAsync(fakeAgent);
162+
163+
const string RequestJson = """
164+
{
165+
"threadId": "thread-123",
166+
"runId": "run-456",
167+
"messages": [{ "id": "msg-1", "role": "user", "content": "test no props" }]
168+
}
169+
""";
170+
171+
using StringContent content = new(RequestJson, Encoding.UTF8, "application/json");
172+
173+
// Act
174+
HttpResponseMessage response = await this._client!.PostAsync(new Uri("/agent", UriKind.Relative), content);
175+
176+
// Assert
177+
response.IsSuccessStatusCode.Should().BeTrue();
178+
fakeAgent.ReceivedForwardedProperties.ValueKind.Should().Be(JsonValueKind.Undefined);
179+
}
180+
181+
[Fact]
182+
public async Task ForwardedProps_ReturnsValidSSEResponse_WithTextDeltaEventsAsync()
183+
{
184+
// Arrange
185+
FakeForwardedPropsAgent fakeAgent = new();
186+
await this.SetupTestServerAsync(fakeAgent);
187+
188+
const string RequestJson = """
189+
{
190+
"threadId": "thread-123",
191+
"runId": "run-456",
192+
"messages": [{ "id": "msg-1", "role": "user", "content": "test response" }],
193+
"forwardedProps": { "customProp": "value" }
194+
}
195+
""";
196+
197+
using StringContent content = new(RequestJson, Encoding.UTF8, "application/json");
198+
199+
// Act
200+
HttpResponseMessage response = await this._client!.PostAsync(new Uri("/agent", UriKind.Relative), content);
201+
response.EnsureSuccessStatusCode();
202+
203+
Stream stream = await response.Content.ReadAsStreamAsync();
204+
List<SseItem<string>> events = [];
205+
await foreach (SseItem<string> item in SseParser.Create(stream).EnumerateAsync())
206+
{
207+
events.Add(item);
208+
}
209+
210+
// Assert
211+
events.Should().NotBeEmpty();
212+
213+
// SSE events have EventType = "message" and the actual type is in the JSON data
214+
// Should have run_started event
215+
events.Should().Contain(e => e.Data != null && e.Data.Contains("\"type\":\"RUN_STARTED\""));
216+
217+
// Should have text_message_start event
218+
events.Should().Contain(e => e.Data != null && e.Data.Contains("\"type\":\"TEXT_MESSAGE_START\""));
219+
220+
// Should have text_message_content event with the response text
221+
events.Should().Contain(e => e.Data != null && e.Data.Contains("\"type\":\"TEXT_MESSAGE_CONTENT\""));
222+
223+
// Should have run_finished event
224+
events.Should().Contain(e => e.Data != null && e.Data.Contains("\"type\":\"RUN_FINISHED\""));
225+
}
226+
227+
[Fact]
228+
public async Task ForwardedProps_WithMixedTypes_AreCorrectlyParsedAsync()
229+
{
230+
// Arrange
231+
FakeForwardedPropsAgent fakeAgent = new();
232+
await this.SetupTestServerAsync(fakeAgent);
233+
234+
const string RequestJson = """
235+
{
236+
"threadId": "thread-123",
237+
"runId": "run-456",
238+
"messages": [{ "id": "msg-1", "role": "user", "content": "test mixed types" }],
239+
"forwardedProps": {
240+
"stringProp": "text",
241+
"numberProp": 42,
242+
"boolProp": true,
243+
"nullProp": null,
244+
"arrayProp": [1, "two", false],
245+
"objectProp": { "nested": "value" }
246+
}
247+
}
248+
""";
249+
250+
using StringContent content = new(RequestJson, Encoding.UTF8, "application/json");
251+
252+
// Act
253+
HttpResponseMessage response = await this._client!.PostAsync(new Uri("/agent", UriKind.Relative), content);
254+
255+
// Assert
256+
response.IsSuccessStatusCode.Should().BeTrue();
257+
fakeAgent.ReceivedForwardedProperties.ValueKind.Should().Be(JsonValueKind.Object);
258+
259+
fakeAgent.ReceivedForwardedProperties.GetProperty("stringProp").GetString().Should().Be("text");
260+
fakeAgent.ReceivedForwardedProperties.GetProperty("numberProp").GetInt32().Should().Be(42);
261+
fakeAgent.ReceivedForwardedProperties.GetProperty("boolProp").GetBoolean().Should().BeTrue();
262+
fakeAgent.ReceivedForwardedProperties.GetProperty("nullProp").ValueKind.Should().Be(JsonValueKind.Null);
263+
fakeAgent.ReceivedForwardedProperties.GetProperty("arrayProp").GetArrayLength().Should().Be(3);
264+
fakeAgent.ReceivedForwardedProperties.GetProperty("objectProp").GetProperty("nested").GetString().Should().Be("value");
265+
}
266+
267+
private async Task SetupTestServerAsync(FakeForwardedPropsAgent fakeAgent)
268+
{
269+
WebApplicationBuilder builder = WebApplication.CreateBuilder();
270+
builder.Services.AddAGUI();
271+
builder.WebHost.UseTestServer();
272+
273+
this._app = builder.Build();
274+
275+
this._app.MapAGUI("/agent", fakeAgent);
276+
277+
await this._app.StartAsync();
278+
279+
TestServer testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
280+
?? throw new InvalidOperationException("TestServer not found");
281+
282+
this._client = testServer.CreateClient();
283+
}
284+
285+
public async ValueTask DisposeAsync()
286+
{
287+
this._client?.Dispose();
288+
if (this._app != null)
289+
{
290+
await this._app.DisposeAsync();
291+
}
292+
}
293+
}
294+
295+
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated in tests")]
296+
internal sealed class FakeForwardedPropsAgent : AIAgent
297+
{
298+
public FakeForwardedPropsAgent()
299+
{
300+
}
301+
302+
public override string? Description => "Agent for forwarded properties testing";
303+
304+
public JsonElement ReceivedForwardedProperties { get; private set; }
305+
306+
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
307+
{
308+
return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
309+
}
310+
311+
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
312+
IEnumerable<ChatMessage> messages,
313+
AgentThread? thread = null,
314+
AgentRunOptions? options = null,
315+
[EnumeratorCancellation] CancellationToken cancellationToken = default)
316+
{
317+
// Extract forwarded properties from ChatOptions.AdditionalProperties (set by AG-UI hosting layer)
318+
if (options is ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } properties } &&
319+
properties.TryGetValue("ag_ui_forwarded_properties", out object? propsObj) &&
320+
propsObj is JsonElement forwardedProps)
321+
{
322+
this.ReceivedForwardedProperties = forwardedProps;
323+
}
324+
325+
// Always return a text response
326+
string messageId = Guid.NewGuid().ToString("N");
327+
yield return new AgentRunResponseUpdate
328+
{
329+
MessageId = messageId,
330+
Role = ChatRole.Assistant,
331+
Contents = [new TextContent("Forwarded props processed")]
332+
};
333+
334+
await Task.CompletedTask;
335+
}
336+
337+
public override AgentThread GetNewThread() => new FakeInMemoryAgentThread();
338+
339+
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
340+
{
341+
return new FakeInMemoryAgentThread(serializedThread, jsonSerializerOptions);
342+
}
343+
344+
private sealed class FakeInMemoryAgentThread : InMemoryAgentThread
345+
{
346+
public FakeInMemoryAgentThread()
347+
: base()
348+
{
349+
}
350+
351+
public FakeInMemoryAgentThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
352+
: base(serializedThread, jsonSerializerOptions)
353+
{
354+
}
355+
}
356+
357+
public override object? GetService(Type serviceType, object? serviceKey = null) => null;
358+
}

0 commit comments

Comments
 (0)